summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/test
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/newtab/test/.eslintrc.js41
-rw-r--r--browser/components/newtab/test/InflightAssetsMessageProvider.jsm342
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser.ini39
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_basic_endtoend.js22
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_bump_version.js35
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_disabled.js97
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_experiments_api_control.js63
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_locale_change.js30
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_no_cache.js27
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_no_cache_on_SessionStartup_restore.js37
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_no_startup_actions.js83
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_overwrite_cache.js38
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_process_crash.js81
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_same_consumer.js52
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_sanitize.js54
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/browser_shutdown_timeout.js45
-rw-r--r--browser/components/newtab/test/browser/abouthomecache/head.js360
-rw-r--r--browser/components/newtab/test/browser/annotation_first.html2
-rw-r--r--browser/components/newtab/test/browser/annotation_second.html2
-rw-r--r--browser/components/newtab/test/browser/annotation_third.html2
-rw-r--r--browser/components/newtab/test/browser/blue_page.html6
-rw-r--r--browser/components/newtab/test/browser/browser.ini112
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_attribution.js214
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_configurable_ui.js668
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_fxa_signin_flow.js303
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_glean.js174
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_import.js106
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_mobile_downloads.js112
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_multistage_default.js736
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_multistage_experimentAPI.js597
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_multistage_languageSwitcher.js705
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_multistage_mr.js621
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_multistage_video.js97
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_observer.js71
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_rtamo.js298
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_screen_targeting.js152
-rw-r--r--browser/components/newtab/test/browser/browser_aboutwelcome_upgrade_multistage_mr.js316
-rw-r--r--browser/components/newtab/test/browser/browser_as_load_location.js44
-rw-r--r--browser/components/newtab/test/browser/browser_as_render.js83
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_bug1761522.js232
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_bug1800087.js48
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_cfr.js914
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js505
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_group_frequency.js190
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_group_userprefs.js160
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_infobar.js226
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_momentspagehub.js116
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_snippets.js190
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_snippets_dismiss.js99
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_targeting.js1697
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_toast_notification.js139
-rw-r--r--browser/components/newtab/test/browser/browser_asrouter_toolbarbadge.js149
-rw-r--r--browser/components/newtab/test/browser/browser_context_menu_item.js18
-rw-r--r--browser/components/newtab/test/browser/browser_customize_menu_content.js222
-rw-r--r--browser/components/newtab/test/browser/browser_customize_menu_render.js27
-rw-r--r--browser/components/newtab/test/browser/browser_discovery_card.js44
-rw-r--r--browser/components/newtab/test/browser/browser_discovery_render.js32
-rw-r--r--browser/components/newtab/test/browser/browser_discovery_styles.js171
-rw-r--r--browser/components/newtab/test/browser/browser_enabled_newtabpage.js33
-rw-r--r--browser/components/newtab/test/browser/browser_feature_callout_in_chrome.js487
-rw-r--r--browser/components/newtab/test/browser/browser_getScreenshots.js90
-rw-r--r--browser/components/newtab/test/browser/browser_highlights_section.js96
-rw-r--r--browser/components/newtab/test/browser/browser_multistage_spotlight.js58
-rw-r--r--browser/components/newtab/test/browser/browser_multistage_spotlight_telemetry.js145
-rw-r--r--browser/components/newtab/test/browser/browser_newtab_header.js76
-rw-r--r--browser/components/newtab/test/browser/browser_newtab_last_LinkMenu.js151
-rw-r--r--browser/components/newtab/test/browser/browser_newtab_overrides.js138
-rw-r--r--browser/components/newtab/test/browser/browser_newtab_ping.js216
-rw-r--r--browser/components/newtab/test/browser/browser_newtab_towindow.js45
-rw-r--r--browser/components/newtab/test/browser/browser_newtab_trigger.js50
-rw-r--r--browser/components/newtab/test/browser/browser_open_tab_focus.js37
-rw-r--r--browser/components/newtab/test/browser/browser_remote_l10n.js56
-rw-r--r--browser/components/newtab/test/browser/browser_topsites_annotation.js980
-rw-r--r--browser/components/newtab/test/browser/browser_topsites_contextMenu_options.js126
-rw-r--r--browser/components/newtab/test/browser/browser_topsites_section.js299
-rw-r--r--browser/components/newtab/test/browser/browser_trigger_listeners.js343
-rw-r--r--browser/components/newtab/test/browser/browser_trigger_messagesLoaded.js152
-rw-r--r--browser/components/newtab/test/browser/ds_layout.json90
-rw-r--r--browser/components/newtab/test/browser/file_pdf.PDF12
-rw-r--r--browser/components/newtab/test/browser/head.js392
-rw-r--r--browser/components/newtab/test/browser/red_page.html6
-rw-r--r--browser/components/newtab/test/browser/redirect_to.sjs9
-rw-r--r--browser/components/newtab/test/browser/snippet.json46
-rw-r--r--browser/components/newtab/test/browser/snippet_below_search_test.json20
-rw-r--r--browser/components/newtab/test/browser/snippet_simple_test.json24
-rw-r--r--browser/components/newtab/test/browser/topstories.json53
-rw-r--r--browser/components/newtab/test/schemas/pings.js304
-rw-r--r--browser/components/newtab/test/unit/aboutwelcome/AWScreenUtils.test.jsx140
-rw-r--r--browser/components/newtab/test/unit/aboutwelcome/CTAParagraph.test.jsx49
-rw-r--r--browser/components/newtab/test/unit/aboutwelcome/HeroImage.test.jsx40
-rw-r--r--browser/components/newtab/test/unit/aboutwelcome/MRColorways.test.jsx328
-rw-r--r--browser/components/newtab/test/unit/aboutwelcome/MobileDownloads.test.jsx69
-rw-r--r--browser/components/newtab/test/unit/aboutwelcome/MultiSelect.test.jsx151
-rw-r--r--browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx564
-rw-r--r--browser/components/newtab/test/unit/aboutwelcome/MultiStageAboutWelcome.test.jsx824
-rw-r--r--browser/components/newtab/test/unit/aboutwelcome/OnboardingVideoTest.test.jsx45
-rw-r--r--browser/components/newtab/test/unit/asrouter/ASRouter.test.js3040
-rw-r--r--browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js74
-rw-r--r--browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js153
-rw-r--r--browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js106
-rw-r--r--browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js428
-rw-r--r--browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js491
-rw-r--r--browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js574
-rw-r--r--browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js778
-rw-r--r--browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js32
-rw-r--r--browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js1252
-rw-r--r--browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js459
-rw-r--r--browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx69
-rw-r--r--browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js217
-rw-r--r--browser/components/newtab/test/unit/asrouter/RichText.test.jsx101
-rw-r--r--browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js43
-rw-r--r--browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js88
-rw-r--r--browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx516
-rw-r--r--browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js100
-rw-r--r--browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js26
-rw-r--r--browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js125
-rw-r--r--browser/components/newtab/test/unit/asrouter/constants.js137
-rw-r--r--browser/components/newtab/test/unit/asrouter/template-utils.test.js31
-rw-r--r--browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx213
-rw-r--r--browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx112
-rw-r--r--browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx106
-rw-r--r--browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx108
-rw-r--r--browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx277
-rw-r--r--browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx81
-rw-r--r--browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx259
-rw-r--r--browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx354
-rw-r--r--browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js56
-rw-r--r--browser/components/newtab/test/unit/common/Actions.test.js236
-rw-r--r--browser/components/newtab/test/unit/common/Dedupe.test.js38
-rw-r--r--browser/components/newtab/test/unit/common/Reducers.test.js1566
-rw-r--r--browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx516
-rw-r--r--browser/components/newtab/test/unit/content-src/components/Base.test.jsx130
-rw-r--r--browser/components/newtab/test/unit/content-src/components/Card.test.jsx510
-rw-r--r--browser/components/newtab/test/unit/content-src/components/CollapsibleSection.test.jsx67
-rw-r--r--browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx447
-rw-r--r--browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx182
-rw-r--r--browser/components/newtab/test/unit/content-src/components/ContextMenu.test.jsx227
-rw-r--r--browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx72
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamBase.test.jsx313
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx354
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx134
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx544
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx138
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx51
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx73
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx146
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx151
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx57
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx50
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSSignup.test.jsx92
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx94
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx41
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx16
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx278
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx131
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/PrivacyLink.test.jsx29
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx56
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx22
-rw-r--r--browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx238
-rw-r--r--browser/components/newtab/test/unit/content-src/components/ErrorBoundary.test.jsx110
-rw-r--r--browser/components/newtab/test/unit/content-src/components/FluentOrText.test.jsx68
-rw-r--r--browser/components/newtab/test/unit/content-src/components/HelpText.test.jsx41
-rw-r--r--browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx582
-rw-r--r--browser/components/newtab/test/unit/content-src/components/MSLocalized.test.jsx48
-rw-r--r--browser/components/newtab/test/unit/content-src/components/MoreRecommendations.test.jsx24
-rw-r--r--browser/components/newtab/test/unit/content-src/components/PocketLoggedInCta.test.jsx46
-rw-r--r--browser/components/newtab/test/unit/content-src/components/Search.test.jsx179
-rw-r--r--browser/components/newtab/test/unit/content-src/components/Sections.test.jsx600
-rw-r--r--browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx1919
-rw-r--r--browser/components/newtab/test/unit/content-src/components/TopSites/SearchShortcutsForm.test.jsx56
-rw-r--r--browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx150
-rw-r--r--browser/components/newtab/test/unit/content-src/components/Topics.test.jsx22
-rw-r--r--browser/components/newtab/test/unit/content-src/components/addUtmParams.test.js35
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js120
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/init-store.test.js207
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/perf-service.test.js89
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js147
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js576
-rw-r--r--browser/components/newtab/test/unit/lib/AboutPreferences.test.js429
-rw-r--r--browser/components/newtab/test/unit/lib/ActivityStream.test.js576
-rw-r--r--browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js432
-rw-r--r--browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js113
-rw-r--r--browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js161
-rw-r--r--browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js3581
-rw-r--r--browser/components/newtab/test/unit/lib/DownloadsManager.test.js373
-rw-r--r--browser/components/newtab/test/unit/lib/FaviconFeed.test.js233
-rw-r--r--browser/components/newtab/test/unit/lib/FilterAdult.test.js112
-rw-r--r--browser/components/newtab/test/unit/lib/HighlightsFeed.test.js822
-rw-r--r--browser/components/newtab/test/unit/lib/LinksCache.test.js16
-rw-r--r--browser/components/newtab/test/unit/lib/MomentsPageHub.test.js336
-rw-r--r--browser/components/newtab/test/unit/lib/NewTabInit.test.js81
-rw-r--r--browser/components/newtab/test/unit/lib/PersistentCache.test.js142
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js95
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js479
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js356
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js456
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js1543
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js134
-rw-r--r--browser/components/newtab/test/unit/lib/PlacesFeed.test.js1245
-rw-r--r--browser/components/newtab/test/unit/lib/PrefsFeed.test.js357
-rw-r--r--browser/components/newtab/test/unit/lib/RecommendationProvider.test.js162
-rw-r--r--browser/components/newtab/test/unit/lib/Screenshots.test.js209
-rw-r--r--browser/components/newtab/test/unit/lib/SectionsManager.test.js897
-rw-r--r--browser/components/newtab/test/unit/lib/ShortUrl.test.js104
-rw-r--r--browser/components/newtab/test/unit/lib/SiteClassifier.test.js252
-rw-r--r--browser/components/newtab/test/unit/lib/Store.test.js305
-rw-r--r--browser/components/newtab/test/unit/lib/SystemTickFeed.test.js76
-rw-r--r--browser/components/newtab/test/unit/lib/TelemetryFeed.test.js2606
-rw-r--r--browser/components/newtab/test/unit/lib/TippyTopProvider.test.js121
-rw-r--r--browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js649
-rw-r--r--browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js934
-rw-r--r--browser/components/newtab/test/unit/lib/TopSitesFeed.test.js3020
-rw-r--r--browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js1903
-rw-r--r--browser/components/newtab/test/unit/lib/UTEventReporting.test.js115
-rw-r--r--browser/components/newtab/test/unit/unit-entry.js684
-rw-r--r--browser/components/newtab/test/unit/utils.js406
-rw-r--r--browser/components/newtab/test/xpcshell/ds_layout.json89
-rw-r--r--browser/components/newtab/test/xpcshell/head.js105
-rw-r--r--browser/components/newtab/test/xpcshell/test_ASRouterTargeting_attribution.js98
-rw-r--r--browser/components/newtab/test/xpcshell/test_ASRouterTargeting_snapshot.js138
-rw-r--r--browser/components/newtab/test/xpcshell/test_ASRouter_getTargetingParameters.js73
-rw-r--r--browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js33
-rw-r--r--browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js251
-rw-r--r--browser/components/newtab/test/xpcshell/test_AboutNewTab.js359
-rw-r--r--browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js69
-rw-r--r--browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js101
-rw-r--r--browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry_glean.js143
-rw-r--r--browser/components/newtab/test/xpcshell/test_CFRMessageProvider.js32
-rw-r--r--browser/components/newtab/test/xpcshell/test_InflightAssetsMessageProvider.js41
-rw-r--r--browser/components/newtab/test/xpcshell/test_OnboardingMessageProvider.js229
-rw-r--r--browser/components/newtab/test/xpcshell/test_PanelTestProvider.js83
-rw-r--r--browser/components/newtab/test/xpcshell/test_reach_experiments.js97
-rw-r--r--browser/components/newtab/test/xpcshell/test_remoteExperiments.js37
-rw-r--r--browser/components/newtab/test/xpcshell/topstories.json53
-rw-r--r--browser/components/newtab/test/xpcshell/xpcshell.ini32
235 files changed, 67792 insertions, 0 deletions
diff --git a/browser/components/newtab/test/.eslintrc.js b/browser/components/newtab/test/.eslintrc.js
new file mode 100644
index 0000000000..5f6628d816
--- /dev/null
+++ b/browser/components/newtab/test/.eslintrc.js
@@ -0,0 +1,41 @@
+/* eslint-disable import/no-commonjs */
+// This config doesn't inhert from top-level eslint config Bug 1780031
+
+const xpcshellTestPaths = ["./unit*/**", "./xpcshell/**"];
+module.exports = {
+ env: {
+ mocha: true,
+ },
+ globals: {
+ assert: true,
+ chai: true,
+ sinon: true,
+ },
+ rules: {
+ "func-name-matching": 0,
+ "import/no-commonjs": 2,
+ "lines-between-class-members": 0,
+ "react/jsx-no-bind": 0,
+ "require-await": 0,
+ },
+ overrides: [
+ {
+ // Exempt all files without a 'test' string in their path name since no-insecure-url
+ // is focussing on the test base
+ files: "*",
+ excludedFiles: ["**/test**", "**/test*/**", "Test*/**"],
+ rules: {
+ "@microsoft/sdl/no-insecure-url": "off",
+ },
+ },
+ {
+ // Disable "no-insecure-url" for all xpcshell test
+ files: xpcshellTestPaths.map(path => `${path}`),
+ rules: {
+ // As long "new HttpServer()" does not support https there is no reason to log warnings
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1742061
+ "@microsoft/sdl/no-insecure-url": "off",
+ },
+ },
+ ],
+};
diff --git a/browser/components/newtab/test/InflightAssetsMessageProvider.jsm b/browser/components/newtab/test/InflightAssetsMessageProvider.jsm
new file mode 100644
index 0000000000..b6e1f6aeb8
--- /dev/null
+++ b/browser/components/newtab/test/InflightAssetsMessageProvider.jsm
@@ -0,0 +1,342 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 generated by:
+// https://github.com/mozilla/messaging-system-inflight-assets/tree/master/scripts/export-all.py
+
+const EXPORTED_SYMBOLS = ["InflightAssetsMessageProvider"];
+
+const InflightAssetsMessageProvider = {
+ getMessages() {
+ return [
+ {
+ id: "MILESTONE_MESSAGE",
+ groups: ["cfr"],
+ content: {
+ anchor_id: "tracking-protection-icon-container",
+ bucket_id: "CFR_MILESTONE_MESSAGE",
+ buttons: {
+ primary: {
+ action: {
+ type: "OPEN_PROTECTION_REPORT",
+ },
+ event: "PROTECTION",
+ label: {
+ string_id: "cfr-doorhanger-milestone-ok-button",
+ },
+ },
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-milestone-close-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ event: "DISMISS",
+ },
+ ],
+ },
+ category: "cfrFeatures",
+ heading_text: {
+ string_id: "cfr-doorhanger-milestone-heading",
+ },
+ layout: "short_message",
+ notification_text: "",
+ skip_address_bar_notifier: true,
+ text: "",
+ },
+ frequency: {
+ lifetime: 7,
+ },
+ targeting:
+ "pageLoad >= 4 && firefoxVersion < 87 && userPrefs.cfrFeatures",
+ template: "milestone_message",
+ trigger: {
+ id: "contentBlocking",
+ params: ["ContentBlockingMilestone"],
+ },
+ },
+ {
+ id: "MILESTONE_MESSAGE_87",
+ groups: ["cfr"],
+ content: {
+ anchor_id: "tracking-protection-icon-container",
+ bucket_id: "CFR_MILESTONE_MESSAGE",
+ buttons: {
+ primary: {
+ action: {
+ type: "OPEN_PROTECTION_REPORT",
+ },
+ event: "PROTECTION",
+ label: {
+ string_id: "cfr-doorhanger-milestone-ok-button",
+ },
+ },
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-milestone-close-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ event: "DISMISS",
+ },
+ ],
+ },
+ category: "cfrFeatures",
+ heading_text: {
+ string_id: "cfr-doorhanger-milestone-heading2",
+ },
+ layout: "short_message",
+ notification_text: "",
+ skip_address_bar_notifier: true,
+ text: "",
+ },
+ frequency: {
+ lifetime: 7,
+ },
+ targeting:
+ "pageLoad >= 4 && firefoxVersion >= 87 && userPrefs.cfrFeatures",
+ template: "milestone_message",
+ trigger: {
+ id: "contentBlocking",
+ params: ["ContentBlockingMilestone"],
+ },
+ },
+ {
+ id: "DOH_ROLLOUT_CONFIRMATION_89",
+ groups: ["cfr"],
+ targeting:
+ "profileAgeCreated < 1572480000000 && ( 'doh-rollout.enabled'|preferenceValue || 'doh-rollout.self-enabled'|preferenceValue || 'doh-rollout.ru.enabled'|preferenceValue || 'doh-rollout.ua.enabled'|preferenceValue ) && !( 'doh-rollout.disable-heuristics'|preferenceValue || 'doh-rollout.skipHeuristicsCheck'|preferenceValue || 'doh-rollout.doorhanger-decision'|preferenceValue ) && firefoxVersion >= 89",
+ template: "infobar",
+ content: {
+ priority: 3,
+ type: "global",
+ text: {
+ string_id: "cfr-doorhanger-doh-body",
+ },
+ buttons: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-doh-primary-button-2",
+ },
+ action: {
+ type: "ACCEPT_DOH",
+ },
+ primary: true,
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-doh-secondary-button",
+ },
+ action: {
+ type: "DISABLE_DOH",
+ },
+ },
+ {
+ label: {
+ string_id: "notification-learnmore-default-label",
+ },
+ supportPage: "dns-over-https",
+ callback: null,
+ action: {
+ type: "CANCEL",
+ },
+ },
+ ],
+ bucket_id: "DOH_ROLLOUT_CONFIRMATION_89",
+ category: "cfrFeatures",
+ },
+ frequency: {
+ lifetime: 3,
+ },
+ trigger: {
+ id: "openURL",
+ patterns: ["*://*/*"],
+ },
+ },
+ {
+ id: "INFOBAR_DEFAULT_AND_PIN_87",
+ groups: ["cfr"],
+ content: {
+ category: "cfrFeatures",
+ bucket_id: "INFOBAR_DEFAULT_AND_PIN_87",
+ text: {
+ string_id: "default-browser-notification-message",
+ },
+ type: "global",
+ buttons: [
+ {
+ label: {
+ string_id: "default-browser-notification-button",
+ },
+ action: {
+ type: "PIN_AND_DEFAULT",
+ },
+ primary: true,
+ accessKey: "P",
+ },
+ ],
+ },
+ trigger: {
+ id: "defaultBrowserCheck",
+ },
+ template: "infobar",
+ frequency: {
+ lifetime: 2,
+ custom: [
+ {
+ period: 3024000000,
+ cap: 1,
+ },
+ ],
+ },
+ targeting:
+ "((firefoxVersion >= 87 && firefoxVersion < 89) || (firefoxVersion >= 89 && source == 'startup')) && !isDefaultBrowser && !'browser.shell.checkDefaultBrowser'|preferenceValue && isMajorUpgrade != true && platformName != 'linux' && ((currentDate|date - profileAgeCreated) / 604800000) >= 5 && !activeNotifications && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && ((currentDate|date - profileAgeCreated) / 604800000) < 15",
+ },
+ {
+ id: "CFR_FULL_VIDEO_SUPPORT_EN",
+ groups: ["cfr"],
+ targeting:
+ "firefoxVersion < 88 && firefoxVersion != 78 && localeLanguageCode in ['en', 'fr', 'de', 'ru', 'zh', 'es', 'it', 'pl']",
+ template: "cfr_doorhanger",
+ content: {
+ skip_address_bar_notifier: true,
+ persistent_doorhanger: true,
+ anchor_id: "PanelUI-menu-button",
+ layout: "icon_and_message",
+ text: {
+ string_id: "cfr-doorhanger-video-support-body",
+ },
+ buttons: {
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-cancel-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ ],
+ primary: {
+ label: {
+ string_id: "cfr-doorhanger-video-support-primary-button",
+ },
+ action: {
+ type: "OPEN_URL",
+ data: {
+ args: "https://support.mozilla.org/kb/update-firefox-latest-release",
+ where: "tabshifted",
+ },
+ },
+ },
+ },
+ bucket_id: "CFR_FULL_VIDEO_SUPPORT_EN",
+ heading_text: {
+ string_id: "cfr-doorhanger-video-support-header",
+ },
+ info_icon: {
+ label: {
+ string_id: "cfr-doorhanger-extension-sumo-link",
+ },
+ sumo_path: "extensionrecommendations",
+ },
+ notification_text: "Message from Firefox",
+ category: "cfrFeatures",
+ },
+ frequency: {
+ lifetime: 3,
+ },
+ trigger: {
+ id: "openURL",
+ patterns: ["https://*/Amazon-Video/*", "https://*/Prime-Video/*"],
+ params: [
+ "www.hulu.com",
+ "hulu.com",
+ "www.netflix.com",
+ "netflix.com",
+ "www.disneyplus.com",
+ "disneyplus.com",
+ "www.hbomax.com",
+ "hbomax.com",
+ "www.sho.com",
+ "sho.com",
+ "www.directv.com",
+ "directv.com",
+ "www.starzplay.com",
+ "starzplay.com",
+ "www.sling.com",
+ "sling.com",
+ "www.facebook.com",
+ "facebook.com",
+ ],
+ },
+ },
+ {
+ id: "WNP_MOMENTS_12",
+ groups: ["moments-pages"],
+ content: {
+ action: {
+ data: {
+ expire: 1640908800000,
+ url: "https://www.mozilla.org/firefox/welcome/12",
+ },
+ id: "moments-wnp",
+ },
+ bucket_id: "WNP_MOMENTS_12",
+ },
+ targeting:
+ 'localeLanguageCode == "en" && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 1 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue',
+ template: "update_action",
+ trigger: {
+ id: "momentsUpdate",
+ },
+ },
+ {
+ id: "WNP_MOMENTS_13",
+ groups: ["moments-pages"],
+ content: {
+ action: {
+ data: {
+ expire: 1640908800000,
+ url: "https://www.mozilla.org/firefox/welcome/13",
+ },
+ id: "moments-wnp",
+ },
+ bucket_id: "WNP_MOMENTS_13",
+ },
+ targeting:
+ '(localeLanguageCode in ["en", "de", "fr", "nl", "it", "ms"] || locale == "es-ES") && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 0 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue',
+ template: "update_action",
+ trigger: {
+ id: "momentsUpdate",
+ },
+ },
+ {
+ id: "WNP_MOMENTS_14",
+ groups: ["moments-pages"],
+ content: {
+ action: {
+ data: {
+ expire: 1668470400000,
+ url: "https://www.mozilla.org/firefox/welcome/14",
+ },
+ id: "moments-wnp",
+ },
+ bucket_id: "WNP_MOMENTS_14",
+ },
+ targeting:
+ 'localeLanguageCode in ["en", "de", "fr"] && region in ["AT", "BE", "CA", "CH", "DE", "ES", "FI", "FR", "GB", "IE", "IT", "MY", "NL", "NZ", "SE", "SG", "US"] && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue',
+ template: "update_action",
+ trigger: {
+ id: "momentsUpdate",
+ },
+ },
+ ];
+ },
+};
diff --git a/browser/components/newtab/test/browser/abouthomecache/browser.ini b/browser/components/newtab/test/browser/abouthomecache/browser.ini
new file mode 100644
index 0000000000..febe76d92e
--- /dev/null
+++ b/browser/components/newtab/test/browser/abouthomecache/browser.ini
@@ -0,0 +1,39 @@
+[DEFAULT]
+support-files =
+ head.js
+ ../ds_layout.json
+ ../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.ping-centre.telemetry=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(
+ `
+ <html>
+ <head>
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; object-src 'none'; script-src resource: chrome:; connect-src https:; img-src https: data: blob:; style-src 'unsafe-inline';">
+ </head>
+ <body>
+ <h1 id="${TEST_ID}">Something new</h1>
+ <div id="root"></div>
+ </body>
+ <script src="about:home?jscache"></script>
+ </html>`,
+ "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..2a26bc553d
--- /dev/null
+++ b/browser/components/newtab/test/browser/abouthomecache/browser_process_crash.js
@@ -0,0 +1,81 @@
+/* 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;
+
+ 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(
+ `
+ <html>
+ <head>
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; object-src 'none'; script-src resource: chrome:; connect-src https:; img-src https: data: blob:; style-src 'unsafe-inline';">
+ </head>
+ <body>
+ <h1>A fake about:home page</h1>
+ <div id="root"></div>
+ </body>
+ </html>`,
+ "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 <body> 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..52be79338e
--- /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.import(
+ "resource:///modules/AboutNewTabService.jsm"
+ );
+ 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..a3c8c1434b
--- /dev/null
+++ b/browser/components/newtab/test/browser/abouthomecache/head.js
@@ -0,0 +1,360 @@
+/* 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"
+);
+
+// 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.import(
+ "resource://activity-stream/lib/ActivityStream.jsm"
+ );
+
+ let defaultDSConfig = JSON.parse(
+ PREFS_CONFIG.get("discoverystream.config").getValue({
+ geo: "US",
+ locale: "en-US",
+ })
+ );
+
+ let newConfig = Object.assign(defaultDSConfig, {
+ show_spocs: false,
+ hardcoded_layout: false,
+ layout_endpoint:
+ "https://example.com/browser/browser/components/newtab/test/browser/ds_layout.json",
+ });
+
+ // 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(newConfig)
+ );
+}
+
+/**
+ * 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}
+ */
+// eslint-disable-next-line no-unused-vars
+function withFullyLoadedAboutHome(taskFn) {
+ 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);
+ });
+}
+
+/**
+ * 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 (<xul:browser>)
+ * A <xul:browser> 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 <xul:browser>
+ * pointed at about:home finishes reloading.
+ */
+// eslint-disable-next-line no-unused-vars
+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.import(
+ "resource:///modules/AboutNewTabService.jsm"
+ );
+ 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.import(
+ "resource:///modules/AboutNewTabService.jsm"
+ );
+ 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.loadURIString(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.
+ */
+// eslint-disable-next-line no-unused-vars
+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.
+ */
+// eslint-disable-next-line no-unused-vars
+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 <xul:browser> 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 <script> element
+ * 2. The __FROM_STARTUP_CACHE__ expando on the window
+ * 3. The "activity-stream" class on the document body
+ * 4. The top sites section
+ *
+ * @param browser (<xul:browser>)
+ * A <xul:browser> with about:home running in it.
+ * @returns Promise
+ * @resolves undefined
+ * Resolves once the cache entry has been destroyed.
+ */
+// eslint-disable-next-line no-unused-vars
+async function ensureCachedAboutHome(browser) {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let scripts = Array.from(content.document.querySelectorAll("script"));
+ Assert.ok(!!scripts.length, "There should be page scripts.");
+ let [lastScript] = scripts.reverse();
+ Assert.equal(
+ lastScript.src,
+ "about:home?jscache",
+ "Found about:home?jscache script tag, indicating the cached doc"
+ );
+ Assert.ok(
+ Cu.waiveXrays(content).__FROM_STARTUP_CACHE__,
+ "Should have found window.__FROM_STARTUP_CACHE__"
+ );
+ Assert.ok(
+ content.document.body.classList.contains("activity-stream"),
+ "Should have found activity-stream class on <body> element"
+ );
+ Assert.ok(
+ content.document.querySelector("[data-section-id='topsites']"),
+ "Should have found the Discovery Stream top sites."
+ );
+ });
+ assertCacheResultScalar(
+ AboutHomeStartupCache.CACHE_RESULT_SCALARS.VALID_AND_USED
+ );
+}
+
+/**
+ * Tests that the about:home document loaded in a passed <xul:browser> was
+ * dynamically generated, and _not_ from the cache.
+ *
+ * We test for this by looking for some tell-tale signs of the dynamically
+ * generated document:
+ *
+ * 1. No <script> elements (the scripts are loaded from the ScriptPreloader
+ * via AboutNewTabChild when the "privileged about content process" is
+ * enabled)
+ * 2. No __FROM_STARTUP_CACHE__ expando on the window
+ * 3. The "activity-stream" class on the document body
+ * 4. The top sites section
+ *
+ * @param browser (<xul:browser>)
+ * A <xul:browser> with about:home running in it.
+ * @param expectedResultScalar (Number)
+ * One of the AboutHomeStartupCache.CACHE_RESULT_SCALARS values. It is
+ * asserted that the cache result Telemetry scalar will have been set
+ * to this value to explain why the dynamic about:home was used.
+ * @returns Promise
+ * @resolves undefined
+ * Resolves once the cache entry has been destroyed.
+ */
+// eslint-disable-next-line no-unused-vars
+async function ensureDynamicAboutHome(browser, expectedResultScalar) {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let scripts = Array.from(content.document.querySelectorAll("script"));
+ Assert.equal(scripts.length, 0, "There should be no page scripts.");
+
+ Assert.equal(
+ Cu.waiveXrays(content).__FROM_STARTUP_CACHE__,
+ undefined,
+ "Should not have found window.__FROM_STARTUP_CACHE__"
+ );
+
+ Assert.ok(
+ content.document.body.classList.contains("activity-stream"),
+ "Should have found activity-stream class on <body> element"
+ );
+ Assert.ok(
+ content.document.querySelector("[data-section-id='topsites']"),
+ "Should have found the Discovery Stream top sites."
+ );
+ });
+
+ assertCacheResultScalar(expectedResultScalar);
+}
diff --git a/browser/components/newtab/test/browser/annotation_first.html b/browser/components/newtab/test/browser/annotation_first.html
new file mode 100644
index 0000000000..e40ed1db6c
--- /dev/null
+++ b/browser/components/newtab/test/browser/annotation_first.html
@@ -0,0 +1,2 @@
+first
+<a href="annotation_second.html">goto second</a>
diff --git a/browser/components/newtab/test/browser/annotation_second.html b/browser/components/newtab/test/browser/annotation_second.html
new file mode 100644
index 0000000000..8d8bbab6bd
--- /dev/null
+++ b/browser/components/newtab/test/browser/annotation_second.html
@@ -0,0 +1,2 @@
+second
+<a href="https://www.example.com/browser/browser/components/newtab/test/browser/annotation_third.html">goto third</a>
diff --git a/browser/components/newtab/test/browser/annotation_third.html b/browser/components/newtab/test/browser/annotation_third.html
new file mode 100644
index 0000000000..b63f85fe1f
--- /dev/null
+++ b/browser/components/newtab/test/browser/annotation_third.html
@@ -0,0 +1,2 @@
+thrid
+<a href="https://example.org/">goto outside</a>
diff --git a/browser/components/newtab/test/browser/blue_page.html b/browser/components/newtab/test/browser/blue_page.html
new file mode 100644
index 0000000000..e7eaba1e1c
--- /dev/null
+++ b/browser/components/newtab/test/browser/blue_page.html
@@ -0,0 +1,6 @@
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body style="background-color: blue" />
+</html>
diff --git a/browser/components/newtab/test/browser/browser.ini b/browser/components/newtab/test/browser/browser.ini
new file mode 100644
index 0000000000..9979b4f877
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser.ini
@@ -0,0 +1,112 @@
+[DEFAULT]
+support-files =
+ blue_page.html
+ red_page.html
+ annotation_first.html
+ annotation_second.html
+ annotation_third.html
+ head.js
+ redirect_to.sjs
+ snippet.json
+ snippet_below_search_test.json
+ snippet_simple_test.json
+ topstories.json
+ ds_layout.json
+ file_pdf.PDF
+prefs =
+ browser.newtabpage.activity-stream.debug=false
+ browser.newtabpage.activity-stream.discoverystream.enabled=true
+ 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.section.topstories.options={"provider_name":""}
+ messaging-system.log=all
+ intl.multilingual.aboutWelcome.languageMismatchEnabled=false
+
+[browser_aboutwelcome_attribution.js]
+skip-if =
+ os == "linux" # Test setup only implemented for OSX and Windows
+ os == "mac" && bits == 64 # See bug 1784121
+ os == "win" && msix # These tests rely on the ability to write postSigningData, which we can't do in MSIX builds. https://bugzilla.mozilla.org/show_bug.cgi?id=1805911
+[browser_aboutwelcome_configurable_ui.js]
+skip-if =
+ os == "linux" && bits == 64 && debug # Bug 1784548
+[browser_aboutwelcome_fxa_signin_flow.js]
+[browser_aboutwelcome_glean.js]
+[browser_aboutwelcome_import.js]
+[browser_aboutwelcome_mobile_downloads.js]
+[browser_aboutwelcome_multistage_default.js]
+[browser_aboutwelcome_multistage_experimentAPI.js]
+[browser_aboutwelcome_multistage_languageSwitcher.js]
+skip-if =
+ os == 'linux' && bits == 64 # Bug 1757875
+[browser_aboutwelcome_multistage_mr.js]
+skip-if = os == 'linux' && bits == 64 && debug #Bug 1812050
+[browser_aboutwelcome_multistage_video.js]
+[browser_aboutwelcome_observer.js]
+https_first_disabled = true
+[browser_aboutwelcome_rtamo.js]
+skip-if =
+ os == "linux" # Test setup only implemented for OSX and Windows
+ os == "mac" && bits == 64 # See bug 1784121
+ os == "win" && msix # These tests rely on the ability to write postSigningData, which we can't do in MSIX builds. https://bugzilla.mozilla.org/show_bug.cgi?id=1805911
+[browser_aboutwelcome_screen_targeting.js]
+[browser_aboutwelcome_upgrade_multistage_mr.js]
+[browser_as_load_location.js]
+[browser_as_render.js]
+[browser_asrouter_bug1761522.js]
+[browser_asrouter_bug1800087.js]
+[browser_asrouter_cfr.js]
+https_first_disabled = true
+[browser_asrouter_experimentsAPILoader.js]
+[browser_asrouter_group_frequency.js]
+https_first_disabled = true
+[browser_asrouter_group_userprefs.js]
+skip-if =
+ os == 'linux' && bits == 64 && !debug # Bug 1643036
+[browser_asrouter_infobar.js]
+[browser_asrouter_momentspagehub.js]
+tags = remote-settings
+[browser_asrouter_snippets.js]
+https_first_disabled = true
+[browser_asrouter_snippets_dismiss.js]
+support-files=
+ ../../../../base/content/aboutRobots-icon.png
+[browser_asrouter_targeting.js]
+[browser_asrouter_toast_notification.js]
+[browser_asrouter_toolbarbadge.js]
+tags = remote-settings
+[browser_context_menu_item.js]
+[browser_customize_menu_content.js]
+skip-if = (os == "linux" && tsan) #Bug 1687896
+https_first_disabled = true
+[browser_customize_menu_render.js]
+[browser_discovery_card.js]
+[browser_discovery_render.js]
+[browser_discovery_styles.js]
+[browser_enabled_newtabpage.js]
+[browser_feature_callout_in_chrome.js]
+[browser_getScreenshots.js]
+[browser_highlights_section.js]
+[browser_multistage_spotlight.js]
+[browser_multistage_spotlight_telemetry.js]
+skip-if = verify # bug 1834620 - order of events not stable
+[browser_newtab_header.js]
+[browser_newtab_last_LinkMenu.js]
+[browser_newtab_overrides.js]
+[browser_newtab_ping.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_newtab_towindow.js]
+[browser_newtab_trigger.js]
+[browser_open_tab_focus.js]
+skip-if = (os == "linux") # Test setup only implemented for OSX and Windows
+[browser_remote_l10n.js]
+[browser_topsites_annotation.js]
+skip-if=
+ os == "linux" && bits == 64 && debug # Bug 1785005
+[browser_topsites_contextMenu_options.js]
+[browser_topsites_section.js]
+[browser_trigger_listeners.js]
+https_first_disabled = true
+[browser_trigger_messagesLoaded.js]
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_attribution.js b/browser/components/newtab/test/browser/browser_aboutwelcome_attribution.js
new file mode 100644
index 0000000000..ae33a383ba
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_attribution.js
@@ -0,0 +1,214 @@
+"use strict";
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { AttributionCode } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+const { AddonRepository } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonRepository.sys.mjs"
+);
+
+const TEST_ATTRIBUTION_DATA = {
+ source: "addons.mozilla.org",
+ medium: "referral",
+ campaign: "non-fx-button",
+ // with the sinon override, the id doesn't matter
+ content: "rta:whatever",
+};
+
+const TEST_ADDON_INFO = [
+ {
+ name: "Test Add-on",
+ sourceURI: { scheme: "https", spec: "https://test.xpi" },
+ icons: { 32: "test.png", 64: "test.png" },
+ type: "extension",
+ },
+];
+
+const TEST_UA_ATTRIBUTION_DATA = {
+ ua: "chrome",
+};
+
+const TEST_PROTON_CONTENT = [
+ {
+ id: "AW_STEP1",
+ content: {
+ title: "Step 1",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ has_noodles: true,
+ },
+ },
+ {
+ id: "AW_STEP2",
+ content: {
+ title: "Step 2",
+ primary_button: {
+ label: {
+ string_id: "onboarding-multistage-import-primary-button-label",
+ },
+ action: {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: {},
+ },
+ },
+ has_noodles: true,
+ },
+ },
+];
+
+async function openRTAMOWithAttribution() {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(AddonRepository, "getAddonsByIDs").resolves(TEST_ADDON_INFO);
+
+ await AttributionCode.deleteFileAsync();
+ await ASRouter.forceAttribution(TEST_ATTRIBUTION_DATA);
+
+ AttributionCode._clearCache();
+ const data = await AttributionCode.getAttrDataAsync();
+
+ Assert.equal(
+ data.source,
+ "addons.mozilla.org",
+ "Attribution data should be set"
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ await ASRouter.forceAttribution("");
+ sandbox.restore();
+ });
+ return tab.linkedBrowser;
+}
+
+/**
+ * Setup and test RTAMO welcome UI
+ */
+async function test_screen_content(
+ browser,
+ experiment,
+ expectedSelectors = [],
+ unexpectedSelectors = []
+) {
+ await ContentTask.spawn(
+ browser,
+ { expectedSelectors, experiment, unexpectedSelectors },
+ async ({
+ expectedSelectors: expected,
+ experiment: experimentName,
+ unexpectedSelectors: unexpected,
+ }) => {
+ for (let selector of expected) {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(selector),
+ `Should render ${selector} in ${experimentName}`
+ );
+ }
+ for (let selector of unexpected) {
+ ok(
+ !content.document.querySelector(selector),
+ `Should not render ${selector} in ${experimentName}`
+ );
+ }
+ }
+ );
+}
+
+add_task(async function test_rtamo_attribution() {
+ let browser = await openRTAMOWithAttribution();
+
+ await test_screen_content(
+ browser,
+ "RTAMO UI",
+ // Expected selectors:
+ [
+ "div.onboardingContainer",
+ "h2[data-l10n-id='mr1-return-to-amo-addon-title']",
+ "div.rtamo-icon",
+ "button.primary",
+ "button.secondary",
+ ],
+ // Unexpected selectors:
+ [
+ "main.AW_STEP1",
+ "main.AW_STEP2",
+ "main.AW_STEP3",
+ "div.tiles-container.info",
+ ]
+ );
+});
+
+async function openMultiStageWithUserAgentAttribution() {
+ const sandbox = sinon.createSandbox();
+ await ASRouter.forceAttribution(TEST_UA_ATTRIBUTION_DATA);
+ const TEST_PROTON_JSON = JSON.stringify(TEST_PROTON_CONTENT);
+
+ await setAboutWelcomePref(true);
+ await pushPrefs(["browser.aboutwelcome.screens", TEST_PROTON_JSON]);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ await ASRouter.forceAttribution("");
+ sandbox.restore();
+ });
+ return tab.linkedBrowser;
+}
+
+async function onButtonClick(browser, elementId) {
+ await ContentTask.spawn(
+ browser,
+ { elementId },
+ async ({ elementId: buttonId }) => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(buttonId),
+ buttonId
+ );
+ let button = content.document.querySelector(buttonId);
+ button.click();
+ }
+ );
+}
+
+add_task(async function test_ua_attribution() {
+ let browser = await openMultiStageWithUserAgentAttribution();
+
+ await test_screen_content(
+ browser,
+ "multistage step 1 with ua attribution",
+ // Expected selectors:
+ ["div.onboardingContainer", "main.AW_STEP1", "button.primary"],
+ // Unexpected selectors:
+ ["main.AW_STEP2"]
+ );
+
+ await onButtonClick(browser, "button.primary");
+
+ await test_screen_content(
+ browser,
+ "multistage step 2 with ua attribution",
+ // Expected selectors:
+ [
+ "div.onboardingContainer",
+ "main.AW_STEP2",
+ "button.primary[data-l10n-args*='Google Chrome']",
+ ],
+ // Unexpected selectors:
+ ["main.AW_STEP1"]
+ );
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_configurable_ui.js b/browser/components/newtab/test/browser/browser_aboutwelcome_configurable_ui.js
new file mode 100644
index 0000000000..5376c8bf60
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_configurable_ui.js
@@ -0,0 +1,668 @@
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const { AboutWelcomeTelemetry } = ChromeUtils.import(
+ "resource://activity-stream/aboutwelcome/lib/AboutWelcomeTelemetry.jsm"
+);
+
+const BASE_SCREEN_CONTENT = {
+ title: "Step 1",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+};
+
+const makeTestContent = (id, contentAdditions) => {
+ return {
+ id,
+ content: Object.assign({}, BASE_SCREEN_CONTENT, contentAdditions),
+ };
+};
+
+async function openAboutWelcome(json) {
+ if (json) {
+ await setAboutWelcomeMultiStage(json);
+ }
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ });
+ return tab.linkedBrowser;
+}
+
+async function testAboutWelcomeLogoFor(logo = {}) {
+ info(`Testing logo: ${JSON.stringify(logo)}`);
+
+ let screens = [makeTestContent("TEST_LOGO_SELECTION_STEP", { logo })];
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: { enabled: true, screens },
+ });
+
+ let browser = await openAboutWelcome(JSON.stringify(screens));
+
+ let expected = [
+ `.brand-logo[src="${
+ logo.imageURL ?? "chrome://branding/content/about-logo.svg"
+ }"][alt="${logo.alt ?? ""}"]${logo.height ? `[style*="height"]` : ""}${
+ logo.alt ? "" : `[role="presentation"]`
+ }`,
+ ];
+ let unexpected = [];
+ if (!logo.height) {
+ unexpected.push(`.brand-logo[style*="height"]`);
+ }
+ if (logo.alt) {
+ unexpected.push(`.brand-logo[role="presentation"]`);
+ }
+ (logo.darkModeImageURL ? expected : unexpected).push(
+ `.logo-container source[media="(prefers-color-scheme: dark)"]${
+ logo.darkModeImageURL ? `[srcset="${logo.darkModeImageURL}"]` : ""
+ }`
+ );
+ (logo.reducedMotionImageURL ? expected : unexpected).push(
+ `.logo-container source[media="(prefers-reduced-motion: reduce)"]${
+ logo.reducedMotionImageURL
+ ? `[srcset="${logo.reducedMotionImageURL}"]`
+ : ""
+ }`
+ );
+ (logo.darkModeReducedMotionImageURL ? expected : unexpected).push(
+ `.logo-container source[media="(prefers-color-scheme: dark) and (prefers-reduced-motion: reduce)"]${
+ logo.darkModeReducedMotionImageURL
+ ? `[srcset="${logo.darkModeReducedMotionImageURL}"]`
+ : ""
+ }`
+ );
+ await test_screen_content(
+ browser,
+ "renders screen with passed logo",
+ expected,
+ unexpected
+ );
+
+ await doExperimentCleanup();
+ browser.closeBrowser();
+}
+
+/**
+ * Test rendering a screen in about welcome with decorative noodles
+ */
+add_task(async function test_aboutwelcome_with_noodles() {
+ const TEST_NOODLE_CONTENT = makeTestContent("TEST_NOODLE_STEP", {
+ has_noodles: true,
+ });
+ const TEST_NOODLE_JSON = JSON.stringify([TEST_NOODLE_CONTENT]);
+ let browser = await openAboutWelcome(TEST_NOODLE_JSON);
+
+ await test_screen_content(
+ browser,
+ "renders screen with noodles",
+ // Expected selectors:
+ [
+ "main.TEST_NOODLE_STEP[pos='center']",
+ "div.noodle.purple-C",
+ "div.noodle.orange-L",
+ "div.noodle.outline-L",
+ "div.noodle.yellow-circle",
+ ]
+ );
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with a customized logo
+ */
+add_task(async function test_aboutwelcome_with_customized_logo() {
+ const TEST_LOGO_URL = "chrome://branding/content/icon64.png";
+ const TEST_LOGO_HEIGHT = "50px";
+ const TEST_LOGO_CONTENT = makeTestContent("TEST_LOGO_STEP", {
+ logo: {
+ height: TEST_LOGO_HEIGHT,
+ imageURL: TEST_LOGO_URL,
+ },
+ });
+ const TEST_LOGO_JSON = JSON.stringify([TEST_LOGO_CONTENT]);
+ let browser = await openAboutWelcome(TEST_LOGO_JSON);
+
+ await test_screen_content(
+ browser,
+ "renders screen with customized logo",
+ // Expected selectors:
+ ["main.TEST_LOGO_STEP[pos='center']", `.brand-logo[src="${TEST_LOGO_URL}"]`]
+ );
+
+ // Ensure logo has custom height
+ await test_element_styles(
+ browser,
+ ".brand-logo",
+ // Expected styles:
+ {
+ // Override default text-link styles
+ height: TEST_LOGO_HEIGHT,
+ }
+ );
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with empty logo used for padding
+ */
+add_task(async function test_aboutwelcome_with_empty_logo_spacing() {
+ const TEST_LOGO_HEIGHT = "50px";
+ const TEST_LOGO_CONTENT = makeTestContent("TEST_LOGO_STEP", {
+ logo: {
+ height: TEST_LOGO_HEIGHT,
+ imageURL: "none",
+ },
+ });
+ const TEST_LOGO_JSON = JSON.stringify([TEST_LOGO_CONTENT]);
+ let browser = await openAboutWelcome(TEST_LOGO_JSON);
+
+ await test_screen_content(
+ browser,
+ "renders screen with empty logo element",
+ // Expected selectors:
+ ["main.TEST_LOGO_STEP[pos='center']", ".brand-logo[src='none']"]
+ );
+
+ // Ensure logo has custom height
+ await test_element_styles(
+ browser,
+ ".brand-logo",
+ // Expected styles:
+ {
+ // Override default text-link styles
+ height: TEST_LOGO_HEIGHT,
+ }
+ );
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with a title with custom styles.
+ */
+add_task(async function test_aboutwelcome_with_title_styles() {
+ const TEST_TITLE_STYLE_CONTENT = makeTestContent("TEST_TITLE_STYLE_STEP", {
+ title: {
+ fontSize: "36px",
+ fontWeight: 276,
+ letterSpacing: 0,
+ raw: "test",
+ },
+ title_style: "fancy shine",
+ });
+
+ const TEST_TITLE_STYLE_JSON = JSON.stringify([TEST_TITLE_STYLE_CONTENT]);
+ let browser = await openAboutWelcome(TEST_TITLE_STYLE_JSON);
+
+ await test_screen_content(
+ browser,
+ "renders screen with customized title style",
+ // Expected selectors:
+ [`div.welcome-text.fancy.shine`]
+ );
+
+ await test_element_styles(
+ browser,
+ "#mainContentHeader",
+ // Expected styles:
+ {
+ "font-weight": "276",
+ "font-size": "36px",
+ animation: "50s linear 0s infinite normal none running shine",
+ "letter-spacing": "normal",
+ }
+ );
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with an image for the dialog window's background
+ */
+add_task(async function test_aboutwelcome_with_background() {
+ const BACKGROUND_URL =
+ "chrome://activity-stream/content/data/content/assets/confetti.svg";
+ const TEST_BACKGROUND_CONTENT = makeTestContent("TEST_BACKGROUND_STEP", {
+ background: `url(${BACKGROUND_URL}) no-repeat center/cover`,
+ });
+
+ const TEST_BACKGROUND_JSON = JSON.stringify([TEST_BACKGROUND_CONTENT]);
+ let browser = await openAboutWelcome(TEST_BACKGROUND_JSON);
+
+ await test_screen_content(
+ browser,
+ "renders screen with dialog background image",
+ // Expected selectors:
+ [`div.main-content[style*='${BACKGROUND_URL}'`]
+ );
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with a dismiss button
+ */
+add_task(async function test_aboutwelcome_dismiss_button() {
+ let browser = await openAboutWelcome(
+ JSON.stringify(
+ // Use 2 screens to test that the message is dismissed, not navigated
+ [1, 2].map(i =>
+ makeTestContent(`TEST_DISMISS_STEP_${i}`, {
+ dismiss_button: { action: { dismiss: true } },
+ })
+ )
+ )
+ );
+
+ // Click dismiss button
+ await onButtonClick(browser, "button.dismiss-button");
+
+ // Wait for about:home to load
+ await BrowserTestUtils.browserLoaded(browser, false, "about:home");
+ is(browser.currentURI.spec, "about:home", "about:home loaded");
+
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with the "split" position
+ */
+add_task(async function test_aboutwelcome_split_position() {
+ const TEST_SPLIT_STEP = makeTestContent("TEST_SPLIT_STEP", {
+ position: "split",
+ hero_text: "hero test",
+ });
+
+ const TEST_SPLIT_JSON = JSON.stringify([TEST_SPLIT_STEP]);
+ let browser = await openAboutWelcome(TEST_SPLIT_JSON);
+
+ await test_screen_content(
+ browser,
+ "renders screen secondary section containing hero text",
+ // Expected selectors:
+ [`main.screen[pos="split"]`, `.section-secondary`, `.message-text h1`]
+ );
+
+ // Ensure secondary section has split template styling
+ await test_element_styles(
+ browser,
+ "main.screen .section-secondary",
+ // Expected styles:
+ {
+ display: "flex",
+ margin: "auto 0px auto auto",
+ }
+ );
+
+ // Ensure secondary action has button styling
+ await test_element_styles(
+ browser,
+ ".action-buttons .secondary-cta .secondary",
+ // Expected styles:
+ {
+ // Override default text-link styles
+ "background-color": "rgba(21, 20, 26, 0.07)",
+ color: "rgb(21, 20, 26)",
+ }
+ );
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with a URL value and default color for backdrop
+ */
+add_task(async function test_aboutwelcome_with_url_backdrop() {
+ const TEST_BACKDROP_URL = `url("chrome://activity-stream/content/data/content/assets/confetti.svg")`;
+ const TEST_BACKDROP_VALUE = `#212121 ${TEST_BACKDROP_URL} center/cover no-repeat fixed`;
+ const TEST_URL_BACKDROP_CONTENT = makeTestContent("TEST_URL_BACKDROP_STEP");
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ enabled: true,
+ backdrop: TEST_BACKDROP_VALUE,
+ screens: [TEST_URL_BACKDROP_CONTENT],
+ },
+ });
+ let browser = await openAboutWelcome();
+
+ await test_screen_content(
+ browser,
+ "renders screen with background image",
+ // Expected selectors:
+ [`div.outer-wrapper.onboardingContainer[style*='${TEST_BACKDROP_URL}']`]
+ );
+ await doExperimentCleanup();
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with a color name for backdrop
+ */
+add_task(async function test_aboutwelcome_with_color_backdrop() {
+ const TEST_BACKDROP_COLOR = "transparent";
+ const TEST_BACKDROP_COLOR_CONTENT = makeTestContent(
+ "TEST_COLOR_NAME_BACKDROP_STEP"
+ );
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ enabled: true,
+ backdrop: TEST_BACKDROP_COLOR,
+ screens: [TEST_BACKDROP_COLOR_CONTENT],
+ },
+ });
+ let browser = await openAboutWelcome();
+
+ await test_screen_content(
+ browser,
+ "renders screen with background color",
+ // Expected selectors:
+ [`div.outer-wrapper.onboardingContainer[style*='${TEST_BACKDROP_COLOR}']`]
+ );
+ await doExperimentCleanup();
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with a text color override
+ */
+add_task(async function test_aboutwelcome_with_text_color_override() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Override the system color scheme to dark
+ ["ui.systemUsesDarkTheme", 1],
+ ],
+ });
+
+ let screens = [];
+ // we need at least two screens to test the step indicator
+ for (let i = 0; i < 2; i++) {
+ screens.push(
+ makeTestContent("TEST_TEXT_COLOR_OVERRIDE_STEP", {
+ text_color: "dark",
+ background: "white",
+ })
+ );
+ }
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ enabled: true,
+ screens,
+ },
+ });
+ let browser = await openAboutWelcome(JSON.stringify(screens));
+
+ await test_screen_content(
+ browser,
+ "renders screen with dark text",
+ // Expected selectors:
+ [`main.screen.dark-text`, `.indicator.current`, `.indicator:not(.current)`],
+ // Unexpected selectors:
+ [`main.screen.light-text`]
+ );
+
+ // Ensure title inherits light text color
+ await test_element_styles(
+ browser,
+ "#mainContentHeader",
+ // Expected styles:
+ {
+ color: "rgb(21, 20, 26)",
+ }
+ );
+
+ // Ensure next step indicator inherits light color
+ await test_element_styles(
+ browser,
+ ".indicator:not(.current)",
+ // Expected styles:
+ {
+ color: "rgb(251, 251, 254)",
+ }
+ );
+
+ await doExperimentCleanup();
+ await SpecialPowers.popPrefEnv();
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with a "progress bar" style step indicator
+ */
+add_task(async function test_aboutwelcome_with_progress_bar() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["ui.systemUsesDarkTheme", 0],
+ ["ui.prefersReducedMotion", 0],
+ ],
+ });
+ let screens = [];
+ // we need at least three screens to test the progress bar styling
+ for (let i = 0; i < 3; i++) {
+ screens.push(
+ makeTestContent(`TEST_MR_PROGRESS_BAR_${i + 1}`, {
+ position: "split",
+ progress_bar: true,
+ primary_button: {
+ label: "next",
+ action: {
+ navigate: true,
+ },
+ },
+ })
+ );
+ }
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ enabled: true,
+ screens,
+ },
+ });
+ let browser = await openAboutWelcome(JSON.stringify(screens));
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ const progressBar = await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector(".progress-bar")
+ );
+ const indicator = await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector(".indicator")
+ );
+ // Progress bar should have a gray background.
+ is(
+ content.window.getComputedStyle(progressBar)["background-color"],
+ "rgba(21, 20, 26, 0.25)",
+ "Correct progress bar background"
+ );
+
+ const indicatorStyles = content.window.getComputedStyle(indicator);
+ for (let [key, val] of Object.entries({
+ // The filled "completed" element should have
+ // `background-color: var(--checkbox-checked-bgcolor);`
+ "background-color": "rgb(0, 97, 224)",
+ // Base progress bar step styles.
+ height: "6px",
+ "margin-inline": "-1px",
+ "padding-block": "0px",
+ })) {
+ is(indicatorStyles[key], val, `Correct indicator ${key} style`);
+ }
+ const indicatorX = indicator.getBoundingClientRect().x;
+ content.document.querySelector("button.primary").click();
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(".indicator")?.getBoundingClientRect()
+ .x > indicatorX,
+ "Indicator should have grown"
+ );
+ });
+
+ await doExperimentCleanup();
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a message with session history updates disabled
+ */
+add_task(async function test_aboutwelcome_history_updates_disabled() {
+ let screens = [];
+ // we need at least two screens to test the history state
+ for (let i = 1; i < 3; i++) {
+ screens.push(makeTestContent(`TEST_PUSH_STATE_STEP_${i}`));
+ }
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ enabled: true,
+ disableHistoryUpdates: true,
+ screens,
+ },
+ });
+ let browser = await openAboutWelcome(JSON.stringify(screens));
+
+ let startHistoryLength = await SpecialPowers.spawn(browser, [], () => {
+ return content.window.history.length;
+ });
+ // Advance to second screen
+ await onButtonClick(browser, "button.primary");
+ let endHistoryLength = await SpecialPowers.spawn(browser, [], async () => {
+ // Ensure next screen has rendered
+ await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector(".TEST_PUSH_STATE_STEP_2")
+ );
+ return content.window.history.length;
+ });
+
+ ok(
+ startHistoryLength === endHistoryLength,
+ "No entries added to the session's history stack with history updates disabled"
+ );
+
+ await doExperimentCleanup();
+ browser.closeBrowser();
+});
+
+/**
+ * Test rendering a screen with different logos depending on reduced motion and
+ * color scheme preferences
+ */
+add_task(async function test_aboutwelcome_logo_selection() {
+ // Test a screen config that includes every logo parameter
+ await testAboutWelcomeLogoFor({
+ imageURL: "chrome://branding/content/icon16.png",
+ darkModeImageURL: "chrome://branding/content/icon32.png",
+ reducedMotionImageURL: "chrome://branding/content/icon64.png",
+ darkModeReducedMotionImageURL: "chrome://branding/content/icon128.png",
+ alt: "TEST_LOGO_SELECTION_ALT",
+ height: "16px",
+ });
+ // Test a screen config with no animated/static logos
+ await testAboutWelcomeLogoFor({
+ imageURL: "chrome://branding/content/icon16.png",
+ darkModeImageURL: "chrome://branding/content/icon32.png",
+ });
+ // Test a screen config with no dark mode logos
+ await testAboutWelcomeLogoFor({
+ imageURL: "chrome://branding/content/icon16.png",
+ reducedMotionImageURL: "chrome://branding/content/icon64.png",
+ });
+ // Test a screen config that includes only the default logo
+ await testAboutWelcomeLogoFor({
+ imageURL: "chrome://branding/content/icon16.png",
+ });
+ // Test a screen config with no logos
+ await testAboutWelcomeLogoFor();
+});
+
+/**
+ * Test rendering a message that starts on a specific screen
+ */
+add_task(async function test_aboutwelcome_start_screen_configured() {
+ let startScreen = 1;
+ let screens = [];
+ // we need at least two screens to test
+ for (let i = 1; i < 3; i++) {
+ screens.push(makeTestContent(`TEST_START_STEP_${i}`));
+ }
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ enabled: true,
+ startScreen,
+ screens,
+ },
+ });
+
+ let sandbox = sinon.createSandbox();
+ let spy = sandbox.spy(AboutWelcomeTelemetry.prototype, "sendTelemetry");
+
+ let browser = await openAboutWelcome(JSON.stringify(screens));
+
+ let secondScreenShown = await SpecialPowers.spawn(browser, [], async () => {
+ // Ensure screen has rendered
+ await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector(".TEST_START_STEP_2")
+ );
+ return true;
+ });
+
+ ok(
+ secondScreenShown,
+ `Starts on second screen when configured with startScreen index equal to ${startScreen}`
+ );
+ // Wait for screen elements to render before checking impression pings
+ await test_screen_content(
+ browser,
+ "renders second screen elements",
+ // Expected selectors:
+ [`main.screen`, "div.secondary-cta"]
+ );
+
+ let expectedTelemetry = sinon.match({
+ event: "IMPRESSION",
+ message_id: `MR_WELCOME_DEFAULT_${startScreen}_TEST_START_STEP_${
+ startScreen + 1
+ }_${screens.map(({ id }) => id?.split("_")[1]?.[0]).join("")}`,
+ });
+ if (spy.calledWith(expectedTelemetry)) {
+ ok(
+ true,
+ "Impression events have the correct message id with start screen configured"
+ );
+ } else if (spy.called) {
+ ok(
+ false,
+ `Wrong telemetry sent: ${JSON.stringify(
+ spy.getCalls().map(c => c.args[0]),
+ null,
+ 2
+ )}`
+ );
+ } else {
+ ok(false, "No telemetry sent");
+ }
+
+ await doExperimentCleanup();
+ browser.closeBrowser();
+ sandbox.restore();
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_fxa_signin_flow.js b/browser/components/newtab/test/browser/browser_aboutwelcome_fxa_signin_flow.js
new file mode 100644
index 0000000000..9de9acb7b3
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_fxa_signin_flow.js
@@ -0,0 +1,303 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+const TEST_ROOT = "https://example.com/";
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["identity.fxaccounts.remote.root", TEST_ROOT]],
+ });
+});
+
+/**
+ * Tests that the FXA_SIGNIN_FLOW special action resolves to `true` and
+ * closes the FxA sign-in tab if sign-in is successful.
+ */
+add_task(async function test_fxa_sign_success() {
+ let sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ await BrowserTestUtils.withNewTab("about:welcome", async browser => {
+ let fxaTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ let resultPromise = SpecialPowers.spawn(browser, [], async () => {
+ return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", {
+ type: "FXA_SIGNIN_FLOW",
+ });
+ });
+ let fxaTab = await fxaTabPromise;
+ let fxaTabClosing = BrowserTestUtils.waitForTabClosing(fxaTab);
+
+ // We'll fake-out the UIState being in the STATUS_SIGNED_IN status
+ // and not test the actual FxA sign-in mechanism.
+ sandbox.stub(UIState, "get").returns({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "email@example.com",
+ });
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ await fxaTabClosing;
+ Assert.ok(true, "FxA tab automatically closed.");
+ let result = await resultPromise;
+ Assert.ok(result, "FXA_SIGNIN_FLOW action's result should be true");
+ });
+
+ sandbox.restore();
+});
+
+/**
+ * Tests that the FXA_SIGNIN_FLOW action's data.autoClose parameter can
+ * disable the autoclose behavior.
+ */
+add_task(async function test_fxa_sign_success_no_autoclose() {
+ let sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ await BrowserTestUtils.withNewTab("about:welcome", async browser => {
+ let fxaTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ let resultPromise = SpecialPowers.spawn(browser, [], async () => {
+ return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", {
+ type: "FXA_SIGNIN_FLOW",
+ data: { autoClose: false },
+ });
+ });
+ let fxaTab = await fxaTabPromise;
+
+ // We'll fake-out the UIState being in the STATUS_SIGNED_IN status
+ // and not test the actual FxA sign-in mechanism.
+ sandbox.stub(UIState, "get").returns({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "email@example.com",
+ });
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let result = await resultPromise;
+ Assert.ok(result, "FXA_SIGNIN_FLOW should have resolved to true");
+ Assert.ok(!fxaTab.closing, "FxA tab was not asked to close.");
+ BrowserTestUtils.removeTab(fxaTab);
+ });
+
+ sandbox.restore();
+});
+
+/**
+ * Tests that the FXA_SIGNIN_FLOW action resolves to `false` if the tab
+ * closes before sign-in completes.
+ */
+add_task(async function test_fxa_signin_aborted() {
+ await BrowserTestUtils.withNewTab("about:welcome", async browser => {
+ let fxaTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ let resultPromise = SpecialPowers.spawn(browser, [], async () => {
+ return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", {
+ type: "FXA_SIGNIN_FLOW",
+ });
+ });
+ let fxaTab = await fxaTabPromise;
+ Assert.ok(!fxaTab.closing, "FxA tab was not asked to close yet.");
+
+ BrowserTestUtils.removeTab(fxaTab);
+ let result = await resultPromise;
+ Assert.ok(!result, "FXA_SIGNIN_FLOW action's result should be false");
+ });
+});
+
+/**
+ * Tests that the FXA_SIGNIN_FLOW action can open a separate window, if need
+ * be, and that if that window closes, the flow is considered aborted.
+ */
+add_task(async function test_fxa_signin_window_aborted() {
+ await BrowserTestUtils.withNewTab("about:welcome", async browser => {
+ let fxaWindowPromise = BrowserTestUtils.waitForNewWindow();
+ let resultPromise = SpecialPowers.spawn(browser, [], async () => {
+ return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", {
+ type: "FXA_SIGNIN_FLOW",
+ data: {
+ where: "window",
+ },
+ });
+ });
+ let fxaWindow = await fxaWindowPromise;
+ Assert.ok(!fxaWindow.closed, "FxA window was not asked to close yet.");
+
+ await BrowserTestUtils.closeWindow(fxaWindow);
+ let result = await resultPromise;
+ Assert.ok(!result, "FXA_SIGNIN_FLOW action's result should be false");
+ });
+});
+
+/**
+ * Tests that the FXA_SIGNIN_FLOW action can open a separate window, if need
+ * be, and that if sign-in completes, that new window will close automatically.
+ */
+add_task(async function test_fxa_signin_window_success() {
+ let sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ await BrowserTestUtils.withNewTab("about:welcome", async browser => {
+ let fxaWindowPromise = BrowserTestUtils.waitForNewWindow();
+ let resultPromise = SpecialPowers.spawn(browser, [], async () => {
+ return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", {
+ type: "FXA_SIGNIN_FLOW",
+ data: {
+ where: "window",
+ },
+ });
+ });
+ let fxaWindow = await fxaWindowPromise;
+ Assert.ok(!fxaWindow.closed, "FxA window was not asked to close yet.");
+
+ let windowClosed = BrowserTestUtils.windowClosed(fxaWindow);
+
+ // We'll fake-out the UIState being in the STATUS_SIGNED_IN status
+ // and not test the actual FxA sign-in mechanism.
+ sandbox.stub(UIState, "get").returns({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "email@example.com",
+ });
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let result = await resultPromise;
+ Assert.ok(result, "FXA_SIGNIN_FLOW action's result should be true");
+
+ await windowClosed;
+ Assert.ok(fxaWindow.closed, "Sign-in window was automatically closed.");
+ });
+
+ sandbox.restore();
+});
+
+/**
+ * Tests that the FXA_SIGNIN_FLOW action can open a separate window, if need
+ * be, and that if a new tab is opened in that window and the sign-in tab
+ * is closed:
+ *
+ * 1. The new window isn't closed
+ * 2. The sign-in is considered aborted.
+ */
+add_task(async function test_fxa_signin_window_multiple_tabs_aborted() {
+ await BrowserTestUtils.withNewTab("about:welcome", async browser => {
+ let fxaWindowPromise = BrowserTestUtils.waitForNewWindow();
+ let resultPromise = SpecialPowers.spawn(browser, [], async () => {
+ return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", {
+ type: "FXA_SIGNIN_FLOW",
+ data: {
+ where: "window",
+ },
+ });
+ });
+ let fxaWindow = await fxaWindowPromise;
+ Assert.ok(!fxaWindow.closed, "FxA window was not asked to close yet.");
+ let fxaTab = fxaWindow.gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ fxaWindow.gBrowser,
+ "about:blank"
+ );
+ BrowserTestUtils.removeTab(fxaTab);
+
+ let result = await resultPromise;
+ Assert.ok(!result, "FXA_SIGNIN_FLOW action's result should be false");
+ Assert.ok(!fxaWindow.closed, "FxA window was not asked to close.");
+ await BrowserTestUtils.closeWindow(fxaWindow);
+ });
+});
+
+/**
+ * Tests that the FXA_SIGNIN_FLOW action can open a separate window, if need
+ * be, and that if a new tab is opened in that window but then sign-in
+ * completes
+ *
+ * 1. The new window isn't closed, but the sign-in tab is.
+ * 2. The sign-in is considered a success.
+ */
+add_task(async function test_fxa_signin_window_multiple_tabs_success() {
+ let sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ await BrowserTestUtils.withNewTab("about:welcome", async browser => {
+ let fxaWindowPromise = BrowserTestUtils.waitForNewWindow();
+ let resultPromise = SpecialPowers.spawn(browser, [], async () => {
+ return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", {
+ type: "FXA_SIGNIN_FLOW",
+ data: {
+ where: "window",
+ },
+ });
+ });
+ let fxaWindow = await fxaWindowPromise;
+ Assert.ok(!fxaWindow.closed, "FxA window was not asked to close yet.");
+ let fxaTab = fxaWindow.gBrowser.selectedTab;
+
+ // This will open an about:blank tab in the background.
+ await BrowserTestUtils.addTab(fxaWindow.gBrowser);
+ let fxaTabClosed = BrowserTestUtils.waitForTabClosing(fxaTab);
+
+ // We'll fake-out the UIState being in the STATUS_SIGNED_IN status
+ // and not test the actual FxA sign-in mechanism.
+ sandbox.stub(UIState, "get").returns({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "email@example.com",
+ });
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let result = await resultPromise;
+ Assert.ok(result, "FXA_SIGNIN_FLOW action's result should be true");
+ await fxaTabClosed;
+
+ Assert.ok(!fxaWindow.closed, "FxA window was not asked to close.");
+ await BrowserTestUtils.closeWindow(fxaWindow);
+ });
+
+ sandbox.restore();
+});
+
+/**
+ * Tests that we can pass an entrypoint and UTM parameters to the FxA sign-in
+ * page.
+ */
+add_task(async function test_fxa_signin_flow_entrypoint_utm_params() {
+ await BrowserTestUtils.withNewTab("about:welcome", async browser => {
+ let fxaTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ let resultPromise = SpecialPowers.spawn(browser, [], async () => {
+ return content.wrappedJSObject.AWSendToParent("SPECIAL_ACTION", {
+ type: "FXA_SIGNIN_FLOW",
+ data: {
+ entrypoint: "test-entrypoint",
+ extraParams: {
+ utm_test1: "utm_test1",
+ utm_test2: "utm_test2",
+ },
+ },
+ });
+ });
+ let fxaTab = await fxaTabPromise;
+
+ let uriParams = new URLSearchParams(fxaTab.linkedBrowser.currentURI.query);
+ Assert.equal(uriParams.get("entrypoint"), "test-entrypoint");
+ Assert.equal(uriParams.get("utm_test1"), "utm_test1");
+ Assert.equal(uriParams.get("utm_test2"), "utm_test2");
+
+ BrowserTestUtils.removeTab(fxaTab);
+ await resultPromise;
+ });
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_glean.js b/browser/components/newtab/test/browser/browser_aboutwelcome_glean.js
new file mode 100644
index 0000000000..2875c19b12
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_glean.js
@@ -0,0 +1,174 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests for the Glean version of onboarding telemetry.
+ */
+
+const { AboutWelcomeTelemetry } = ChromeUtils.import(
+ "resource://activity-stream/aboutwelcome/lib/AboutWelcomeTelemetry.jsm"
+);
+
+const TEST_DEFAULT_CONTENT = [
+ {
+ id: "AW_STEP1",
+
+ content: {
+ position: "split",
+ title: "Step 1",
+ page: "page 1",
+ source: "test",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ secondary_button_top: {
+ label: "link top",
+ action: {
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "test" },
+ },
+ },
+ help_text: {
+ text: "Here's some sample help text",
+ },
+ },
+ },
+ {
+ id: "AW_STEP2",
+ content: {
+ position: "center",
+ title: "Step 2",
+ page: "page 1",
+ source: "test",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ has_noodles: true,
+ },
+ },
+];
+
+const TEST_DEFAULT_JSON = JSON.stringify(TEST_DEFAULT_CONTENT);
+
+async function openAboutWelcome() {
+ await setAboutWelcomePref(true);
+ await setAboutWelcomeMultiStage(TEST_DEFAULT_JSON);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ });
+ return tab.linkedBrowser;
+}
+
+add_task(async function test_welcome_telemetry() {
+ const sandbox = sinon.createSandbox();
+ // Be sure to stub out PingCentre so it doesn't hit the network.
+ sandbox
+ .stub(AboutWelcomeTelemetry.prototype, "pingCentre")
+ .value({ sendStructuredIngestionPing: () => {} });
+
+ // Have to turn on AS telemetry for anything to be recorded.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.activity-stream.telemetry", true]],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ });
+
+ Services.fog.testResetFOG();
+ // Let's check that there is nothing in the impression event.
+ // This is useful in mochitests because glean inits fairly late in startup.
+ // We want to make sure we are fully initialized during testing so that
+ // when we call testGetValue() we get predictable behavior.
+ Assert.equal(undefined, Glean.messagingSystem.messageId.testGetValue());
+
+ // Setup testBeforeNextSubmit. We do this first, progress onboarding, submit
+ // and then check submission. We put the asserts inside testBeforeNextSubmit
+ // because metric lifetimes are 'ping' and are cleared after submission.
+ // See: https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/instrumentation_tests.html#xpcshell-tests
+ let pingSubmitted = false;
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+
+ const message = Glean.messagingSystem.messageId.testGetValue();
+ // Because of the asynchronous nature of receiving messages, we cannot
+ // guarantee that we will get the same message first. Instead we check
+ // that the one we get is a valid example of that type.
+ Assert.ok(
+ message.startsWith("MR_WELCOME_DEFAULT"),
+ "Ping is of an expected type"
+ );
+ Assert.equal(
+ Glean.messagingSystem.unknownKeyCount.testGetValue(),
+ undefined
+ );
+ });
+
+ let browser = await openAboutWelcome();
+ // `openAboutWelcome` isn't synchronous wrt the onboarding flow impressing.
+ await TestUtils.waitForCondition(
+ () => pingSubmitted,
+ "Ping was submitted, callback was called."
+ );
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ // Let's reset and assert some values in the next button click.
+ pingSubmitted = false;
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+
+ // Sometimes the impression for MR_WELCOME_DEFAULT_0_AW_STEP1_SS reaches
+ // the parent process before the button click does.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1834620
+ if (Glean.messagingSystem.event.testGetValue() === "IMPRESSION") {
+ Assert.equal(
+ Glean.messagingSystem.eventPage.testGetValue(),
+ "about:welcome"
+ );
+ const message = Glean.messagingSystem.messageId.testGetValue();
+ Assert.ok(
+ message.startsWith("MR_WELCOME_DEFAULT"),
+ "Ping is of an expected type"
+ );
+ } else {
+ // This is the common and, to my mind, correct case:
+ // the click coming before the next steps' impression.
+ Assert.equal(Glean.messagingSystem.event.testGetValue(), "CLICK_BUTTON");
+ Assert.equal(
+ Glean.messagingSystem.eventSource.testGetValue(),
+ "primary_button"
+ );
+ Assert.equal(
+ Glean.messagingSystem.messageId.testGetValue(),
+ "MR_WELCOME_DEFAULT_0_AW_STEP1"
+ );
+ }
+ Assert.equal(
+ Glean.messagingSystem.unknownKeyCount.testGetValue(),
+ undefined
+ );
+ });
+ await onButtonClick(browser, "button.primary");
+ Assert.ok(pingSubmitted, "Ping was submitted, callback was called.");
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_import.js b/browser/components/newtab/test/browser/browser_aboutwelcome_import.js
new file mode 100644
index 0000000000..76716ec47f
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_import.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const IMPORT_SCREEN = {
+ id: "AW_IMPORT",
+ content: {
+ primary_button: {
+ label: "import",
+ action: {
+ navigate: true,
+ type: "SHOW_MIGRATION_WIZARD",
+ },
+ },
+ },
+};
+
+const FORCE_LEGACY =
+ Services.prefs.getCharPref(
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "default"
+ ) === "legacy";
+
+add_task(async function test_wait_import_modal() {
+ await setAboutWelcomeMultiStage(
+ JSON.stringify([IMPORT_SCREEN, { id: "AW_NEXT", content: {} }])
+ );
+ const { cleanup, browser } = await openMRAboutWelcome();
+
+ // execution
+ await test_screen_content(
+ browser,
+ "renders IMPORT screen",
+ //Expected selectors
+ ["main.AW_IMPORT", "button[value='primary_button']"],
+
+ //Unexpected selectors:
+ ["main.AW_NEXT"]
+ );
+
+ const wizardPromise = BrowserTestUtils.waitForMigrationWizard(
+ window,
+ FORCE_LEGACY
+ );
+ const prefsTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:preferences"
+ );
+ await onButtonClick(browser, "button.primary");
+ const wizard = await wizardPromise;
+
+ await test_screen_content(
+ browser,
+ "still shows IMPORT screen",
+ //Expected selectors
+ ["main.AW_IMPORT", "button[value='primary_button']"],
+
+ //Unexpected selectors:
+ ["main.AW_NEXT"]
+ );
+
+ await BrowserTestUtils.closeMigrationWizard(wizard, FORCE_LEGACY);
+
+ await test_screen_content(
+ browser,
+ "moved to NEXT screen",
+ //Expected selectors
+ ["main.AW_NEXT"],
+
+ //Unexpected selectors:
+ []
+ );
+
+ // cleanup
+ await SpecialPowers.popPrefEnv(); // for setAboutWelcomeMultiStage
+ BrowserTestUtils.removeTab(prefsTab);
+ await cleanup();
+});
+
+add_task(async function test_wait_import_spotlight() {
+ const spotlightPromise = TestUtils.topicObserved("subdialog-loaded");
+ ChromeUtils.import(
+ "resource://activity-stream/lib/Spotlight.jsm"
+ ).Spotlight.showSpotlightDialog(gBrowser.selectedBrowser, {
+ content: { modal: "tab", screens: [IMPORT_SCREEN] },
+ });
+ const [win] = await spotlightPromise;
+
+ const wizardPromise = BrowserTestUtils.waitForMigrationWizard(
+ window,
+ FORCE_LEGACY
+ );
+ const prefsTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:preferences"
+ );
+ win.document
+ .querySelector(".onboardingContainer button[value='primary_button']")
+ .click();
+ const wizard = await wizardPromise;
+
+ await BrowserTestUtils.closeMigrationWizard(wizard, FORCE_LEGACY);
+
+ // cleanup
+ BrowserTestUtils.removeTab(prefsTab);
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_mobile_downloads.js b/browser/components/newtab/test/browser/browser_aboutwelcome_mobile_downloads.js
new file mode 100644
index 0000000000..bb94d575fe
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_mobile_downloads.js
@@ -0,0 +1,112 @@
+"use strict";
+
+const BASE_CONTENT = {
+ id: "MOBILE_DOWNLOADS",
+ content: {
+ tiles: {
+ type: "mobile_downloads",
+ data: {
+ QR_code: {
+ image_url: "chrome://browser/content/assets/focus-qr-code.svg",
+ alt_text: "Test alt",
+ },
+ email: {
+ link_text: {
+ string_id: "spotlight-focus-promo-email-link",
+ },
+ },
+ marketplace_buttons: ["ios", "android"],
+ },
+ },
+ },
+};
+
+async function openAboutWelcome(json) {
+ if (json) {
+ await setAboutWelcomeMultiStage(json);
+ }
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ });
+ return tab.linkedBrowser;
+}
+
+const ALT_TEXT = BASE_CONTENT.content.tiles.data.QR_code.alt_text;
+
+/**
+ * Test rendering a screen with a mobile downloads tile
+ * including QR code, email, and marketplace elements
+ */
+add_task(async function test_aboutwelcome_mobile_downloads_all() {
+ const TEST_JSON = JSON.stringify([BASE_CONTENT]);
+ let browser = await openAboutWelcome(TEST_JSON);
+
+ await test_screen_content(
+ browser,
+ "renders screen with all mobile download elements",
+ // Expected selectors:
+ [
+ `img.qr-code-image[alt="${ALT_TEXT}"]`,
+ "ul.mobile-download-buttons",
+ "li.android",
+ "li.ios",
+ "button.email-link",
+ ]
+ );
+});
+
+/**
+ * Test rendering a screen with a mobile downloads tile
+ * including only a QR code and marketplace elements
+ */
+add_task(
+ async function test_aboutwelcome_mobile_downloads_qr_and_marketplace() {
+ const SCREEN_CONTENT = structuredClone(BASE_CONTENT);
+ delete SCREEN_CONTENT.content.tiles.data.email;
+ const TEST_JSON = JSON.stringify([SCREEN_CONTENT]);
+ let browser = await openAboutWelcome(TEST_JSON);
+
+ await test_screen_content(
+ browser,
+ "renders screen with QR code and marketplace badges",
+ // Expected selectors:
+ [
+ `img.qr-code-image[alt="${ALT_TEXT}"]`,
+ "ul.mobile-download-buttons",
+ "li.android",
+ "li.ios",
+ ],
+ // Unexpected selectors:
+ [`button.email-link`]
+ );
+ }
+);
+
+/**
+ * Test rendering a screen with a mobile downloads tile
+ * including only a QR code
+ */
+add_task(async function test_aboutwelcome_mobile_downloads_qr() {
+ let SCREEN_CONTENT = structuredClone(BASE_CONTENT);
+ const QR_CODE_SRC = SCREEN_CONTENT.content.tiles.data.QR_code.image_url;
+
+ delete SCREEN_CONTENT.content.tiles.data.email;
+ delete SCREEN_CONTENT.content.tiles.data.marketplace_buttons;
+ const TEST_JSON = JSON.stringify([SCREEN_CONTENT]);
+ let browser = await openAboutWelcome(TEST_JSON);
+
+ await test_screen_content(
+ browser,
+ "renders screen with QR code",
+ // Expected selectors:
+ [`img.qr-code-image[alt="${ALT_TEXT}"][src="${QR_CODE_SRC}"]`],
+ // Unexpected selectors:
+ ["button.email-link", "li.android", "li.ios"]
+ );
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_default.js b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_default.js
new file mode 100644
index 0000000000..9d578db93d
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_default.js
@@ -0,0 +1,736 @@
+"use strict";
+const { SpecialMessageActions } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs"
+);
+
+const DID_SEE_ABOUT_WELCOME_PREF = "trailhead.firstrun.didSeeAboutWelcome";
+
+const TEST_DEFAULT_CONTENT = [
+ {
+ id: "AW_STEP1",
+ content: {
+ position: "split",
+ title: "Step 1",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ secondary_button_top: {
+ label: "link top",
+ action: {
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "test" },
+ },
+ },
+ help_text: {
+ text: "Here's some sample help text",
+ },
+ },
+ },
+ {
+ id: "AW_STEP2",
+ content: {
+ position: "center",
+ title: "Step 2",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ has_noodles: true,
+ },
+ },
+ {
+ id: "AW_STEP3",
+ content: {
+ title: "Step 3",
+ tiles: {
+ type: "theme",
+ action: {
+ theme: "<event>",
+ },
+ data: [
+ {
+ theme: "automatic",
+ label: "theme-1",
+ tooltip: "test-tooltip",
+ },
+ {
+ theme: "dark",
+ label: "theme-2",
+ },
+ ],
+ },
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "Import",
+ action: {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: { source: "chrome" },
+ },
+ },
+ },
+ },
+ {
+ id: "AW_STEP4",
+ auto_advance: "primary_button",
+ content: {
+ title: "Step 4",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ },
+ },
+];
+
+const TEST_DEFAULT_JSON = JSON.stringify(TEST_DEFAULT_CONTENT);
+
+async function openAboutWelcome() {
+ await setAboutWelcomePref(true);
+ await setAboutWelcomeMultiStage(TEST_DEFAULT_JSON);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ });
+ return tab.linkedBrowser;
+}
+
+/**
+ * Test the multistage welcome default UI
+ */
+add_task(async function test_multistage_aboutwelcome_default() {
+ const sandbox = sinon.createSandbox();
+ let browser = await openAboutWelcome();
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ // Stub AboutWelcomeParent Content Message Handler
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ await test_screen_content(
+ browser,
+ "multistage step 1",
+ // Expected selectors:
+ [
+ "main.AW_STEP1",
+ "div.onboardingContainer",
+ "div.section-secondary",
+ "span.attrib-text",
+ "div.secondary-cta.top",
+ "div.steps",
+ "div.indicator.current",
+ ],
+ // Unexpected selectors:
+ [
+ "main.AW_STEP2",
+ "main.AW_STEP3",
+ "main.dialog-initial",
+ "main.dialog-last",
+ ]
+ );
+
+ await onButtonClick(browser, "button.primary");
+
+ const { callCount } = aboutWelcomeActor.onContentMessage;
+ ok(callCount >= 1, `${callCount} Stub was called`);
+ let clickCall;
+ for (let i = 0; i < callCount; i++) {
+ const call = aboutWelcomeActor.onContentMessage.getCall(i);
+ info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`);
+ if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) {
+ clickCall = call;
+ }
+ }
+
+ Assert.ok(
+ clickCall.args[1].message_id === "MR_WELCOME_DEFAULT_0_AW_STEP1",
+ "AboutWelcome MR message id joined with screen id"
+ );
+
+ await test_screen_content(
+ browser,
+ "multistage step 2",
+ // Expected selectors:
+ [
+ "main.AW_STEP2",
+ "div.onboardingContainer",
+ "div.section-main",
+ "div.steps",
+ "div.indicator.current",
+ "main.with-noodles",
+ ],
+ // Unexpected selectors:
+ [
+ "main.AW_STEP1",
+ "main.AW_STEP3",
+ "div.section-secondary",
+ "main.dialog-last",
+ ]
+ );
+
+ await onButtonClick(browser, "button.primary");
+
+ // No 3rd screen to go to for win7.
+ if (win7Content) return;
+
+ await test_screen_content(
+ browser,
+ "multistage step 3",
+ // Expected selectors:
+ [
+ "main.AW_STEP3",
+ "div.onboardingContainer",
+ "div.section-main",
+ "div.tiles-theme-container",
+ "div.steps",
+ "div.indicator.current",
+ ],
+ // Unexpected selectors:
+ [
+ "main.AW_STEP2",
+ "main.AW_STEP1",
+ "div.section-secondary",
+ "main.dialog-initial",
+ "main.with-noodles",
+ "main.dialog-last",
+ ]
+ );
+
+ await onButtonClick(browser, "button.primary");
+
+ await test_screen_content(
+ browser,
+ "multistage step 4",
+ // Expected selectors:
+ [
+ "main.AW_STEP4.screen-1",
+ "main.AW_STEP4.dialog-last",
+ "div.onboardingContainer",
+ ],
+ // Unexpected selectors:
+ [
+ "main.AW_STEP2",
+ "main.AW_STEP1",
+ "main.AW_STEP3",
+ "div.steps",
+ "main.dialog-initial",
+ "main.AW_STEP4.screen-0",
+ "main.AW_STEP4.screen-2",
+ "main.AW_STEP4.screen-3",
+ ]
+ );
+});
+
+/**
+ * Test navigating back/forward between screens
+ */
+add_task(async function test_Multistage_About_Welcome_navigation() {
+ let browser = await openAboutWelcome();
+
+ await onButtonClick(browser, "button.primary");
+ await TestUtils.waitForCondition(() => browser.canGoBack);
+ browser.goBack();
+
+ await test_screen_content(
+ browser,
+ "multistage step 1",
+ // Expected selectors:
+ [
+ "div.onboardingContainer",
+ "main.AW_STEP1",
+ "div.secondary-cta",
+ "div.secondary-cta.top",
+ "button[value='secondary_button']",
+ "button[value='secondary_button_top']",
+ ],
+ // Unexpected selectors:
+ ["main.AW_STEP2", "main.AW_STEP3"]
+ );
+
+ await document.getElementById("forward-button").click();
+});
+
+/**
+ * Test the multistage welcome UI primary button action
+ */
+add_task(async function test_AWMultistage_Primary_Action() {
+ let browser = await openAboutWelcome();
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ const sandbox = sinon.createSandbox();
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ await onButtonClick(browser, "button.primary");
+ const { callCount } = aboutWelcomeActor.onContentMessage;
+ ok(callCount >= 1, `${callCount} Stub was called`);
+
+ let clickCall;
+ let performanceCall;
+ for (let i = 0; i < callCount; i++) {
+ const call = aboutWelcomeActor.onContentMessage.getCall(i);
+ info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`);
+ if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) {
+ clickCall = call;
+ } else if (
+ call.calledWithMatch("", {
+ event_context: { mountStart: sinon.match.number },
+ })
+ ) {
+ performanceCall = call;
+ }
+ }
+
+ // For some builds, we can stub fast enough to catch the performance
+ if (performanceCall) {
+ Assert.equal(
+ performanceCall.args[0],
+ "AWPage:TELEMETRY_EVENT",
+ "send telemetry event"
+ );
+ Assert.equal(
+ performanceCall.args[1].event,
+ "IMPRESSION",
+ "performance impression event recorded in telemetry"
+ );
+ Assert.equal(
+ typeof performanceCall.args[1].event_context.domComplete,
+ "number",
+ "numeric domComplete recorded in telemetry"
+ );
+ Assert.equal(
+ typeof performanceCall.args[1].event_context.domInteractive,
+ "number",
+ "numeric domInteractive recorded in telemetry"
+ );
+ Assert.equal(
+ typeof performanceCall.args[1].event_context.mountStart,
+ "number",
+ "numeric mountStart recorded in telemetry"
+ );
+ Assert.equal(
+ performanceCall.args[1].message_id,
+ "MR_WELCOME_DEFAULT",
+ "MessageId sent in performance event telemetry"
+ );
+ }
+
+ Assert.equal(
+ clickCall.args[0],
+ "AWPage:TELEMETRY_EVENT",
+ "send telemetry event"
+ );
+ Assert.equal(
+ clickCall.args[1].event,
+ "CLICK_BUTTON",
+ "click button event recorded in telemetry"
+ );
+ Assert.equal(
+ clickCall.args[1].event_context.source,
+ "primary_button",
+ "primary button click source recorded in telemetry"
+ );
+ Assert.equal(
+ clickCall.args[1].message_id,
+ "MR_WELCOME_DEFAULT_0_AW_STEP1",
+ "MessageId sent in click event telemetry"
+ );
+});
+
+add_task(async function test_AWMultistage_Secondary_Open_URL_Action() {
+ if (win7Content) return;
+ let browser = await openAboutWelcome();
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ const sandbox = sinon.createSandbox();
+ // Stub AboutWelcomeParent Content Message Handler
+ sandbox.stub(aboutWelcomeActor, "onContentMessage").resolves(null);
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ await onButtonClick(browser, "button[value='secondary_button_top']");
+ const { callCount } = aboutWelcomeActor.onContentMessage;
+ ok(
+ callCount >= 2,
+ `${callCount} Stub called twice to handle FxA open URL and Telemetry`
+ );
+
+ let actionCall;
+ let eventCall;
+ for (let i = 0; i < callCount; i++) {
+ const call = aboutWelcomeActor.onContentMessage.getCall(i);
+ info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`);
+ if (call.calledWithMatch("SPECIAL")) {
+ actionCall = call;
+ } else if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) {
+ eventCall = call;
+ }
+ }
+
+ Assert.equal(
+ actionCall.args[0],
+ "AWPage:SPECIAL_ACTION",
+ "Got call to handle special action"
+ );
+ Assert.equal(
+ actionCall.args[1].type,
+ "SHOW_FIREFOX_ACCOUNTS",
+ "Special action SHOW_FIREFOX_ACCOUNTS event handled"
+ );
+ Assert.equal(
+ actionCall.args[1].data.extraParams.utm_term,
+ "aboutwelcome-default-screen",
+ "UTMTerm set in FxA URL"
+ );
+ Assert.equal(
+ actionCall.args[1].data.entrypoint,
+ "test",
+ "EntryPoint set in FxA URL"
+ );
+ Assert.equal(
+ eventCall.args[0],
+ "AWPage:TELEMETRY_EVENT",
+ "Got call to handle Telemetry event"
+ );
+ Assert.equal(
+ eventCall.args[1].event,
+ "CLICK_BUTTON",
+ "click button event recorded in Telemetry"
+ );
+ Assert.equal(
+ eventCall.args[1].event_context.source,
+ "secondary_button_top",
+ "secondary_top button click source recorded in Telemetry"
+ );
+});
+
+add_task(async function test_AWMultistage_Themes() {
+ // No theme screen to test for win7.
+ if (win7Content) return;
+
+ let browser = await openAboutWelcome();
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+
+ const sandbox = sinon.createSandbox();
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+ await onButtonClick(browser, "button.primary");
+
+ await test_screen_content(
+ browser,
+ "multistage proton step 2",
+ // Expected selectors:
+ ["main.AW_STEP2"],
+ // Unexpected selectors:
+ ["main.AW_STEP1"]
+ );
+ await onButtonClick(browser, "button.primary");
+
+ await ContentTask.spawn(browser, "Themes", async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("label.theme"),
+ "Theme Icons"
+ );
+ let themes = content.document.querySelectorAll("label.theme");
+ Assert.equal(themes.length, 2, "Two themes displayed");
+ });
+
+ await onButtonClick(browser, "input[value=automatic]");
+
+ const { callCount } = aboutWelcomeActor.onContentMessage;
+ ok(callCount >= 1, `${callCount} Stub was called`);
+
+ let actionCall;
+ let eventCall;
+ for (let i = 0; i < callCount; i++) {
+ const call = aboutWelcomeActor.onContentMessage.getCall(i);
+ info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`);
+ if (call.calledWithMatch("SELECT_THEME")) {
+ actionCall = call;
+ } else if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) {
+ eventCall = call;
+ }
+ }
+
+ Assert.equal(
+ actionCall.args[0],
+ "AWPage:SELECT_THEME",
+ "Got call to handle select theme"
+ );
+ Assert.equal(
+ actionCall.args[1],
+ "AUTOMATIC",
+ "Theme value passed as AUTOMATIC"
+ );
+ Assert.equal(
+ eventCall.args[0],
+ "AWPage:TELEMETRY_EVENT",
+ "Got call to handle Telemetry event when theme tile clicked"
+ );
+ Assert.equal(
+ eventCall.args[1].event,
+ "CLICK_BUTTON",
+ "click button event recorded in Telemetry"
+ );
+ Assert.equal(
+ eventCall.args[1].event_context.source,
+ "automatic",
+ "automatic click source recorded in Telemetry"
+ );
+});
+
+add_task(async function test_AWMultistage_can_restore_theme() {
+ const { XPIProvider } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => sandbox.restore());
+
+ const fakeAddons = [];
+ class FakeAddon {
+ constructor({ id = "default-theme@mozilla.org", isActive = false } = {}) {
+ this.id = id;
+ this.isActive = isActive;
+ }
+ enable() {
+ for (let addon of fakeAddons) {
+ addon.isActive = false;
+ }
+ this.isActive = true;
+ }
+ }
+ fakeAddons.push(
+ new FakeAddon({ id: "fake-theme-1@mozilla.org", isActive: true }),
+ new FakeAddon({ id: "fake-theme-2@mozilla.org" })
+ );
+
+ let browser = await openAboutWelcome();
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+
+ sandbox.stub(XPIProvider, "getAddonsByTypes").resolves(fakeAddons);
+ sandbox
+ .stub(XPIProvider, "getAddonByID")
+ .callsFake(id => fakeAddons.find(addon => addon.id === id));
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+
+ // Test that the active theme ID is stored in LIGHT_WEIGHT_THEMES
+ await aboutWelcomeActor.receiveMessage({
+ name: "AWPage:GET_SELECTED_THEME",
+ });
+ Assert.equal(
+ await aboutWelcomeActor.onContentMessage.lastCall.returnValue,
+ "automatic",
+ `Should return "automatic" for non-built-in theme`
+ );
+
+ await aboutWelcomeActor.receiveMessage({
+ name: "AWPage:SELECT_THEME",
+ data: "AUTOMATIC",
+ });
+ Assert.equal(
+ XPIProvider.getAddonByID.lastCall.args[0],
+ fakeAddons[0].id,
+ `LIGHT_WEIGHT_THEMES.AUTOMATIC should be ${fakeAddons[0].id}`
+ );
+
+ // Enable a different theme...
+ fakeAddons[1].enable();
+ // And test that AWGetSelectedTheme updates the active theme ID
+ await aboutWelcomeActor.receiveMessage({
+ name: "AWPage:GET_SELECTED_THEME",
+ });
+ await aboutWelcomeActor.receiveMessage({
+ name: "AWPage:SELECT_THEME",
+ data: "AUTOMATIC",
+ });
+ Assert.equal(
+ XPIProvider.getAddonByID.lastCall.args[0],
+ fakeAddons[1].id,
+ `LIGHT_WEIGHT_THEMES.AUTOMATIC should be ${fakeAddons[1].id}`
+ );
+});
+
+add_task(async function test_AWMultistage_Import() {
+ // No import screen to test for win7.
+ if (win7Content) return;
+ let browser = await openAboutWelcome();
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+
+ // Click twice to advance to screen 3
+ await onButtonClick(browser, "button.primary");
+ await test_screen_content(
+ browser,
+ "multistage proton step 2",
+ // Expected selectors:
+ ["main.AW_STEP2"],
+ // Unexpected selectors:
+ ["main.AW_STEP1"]
+ );
+ await onButtonClick(browser, "button.primary");
+
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(SpecialMessageActions, "handleAction");
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+
+ await test_screen_content(
+ browser,
+ "multistage proton step 2",
+ // Expected selectors:
+ ["main.AW_STEP3"],
+ // Unexpected selectors:
+ ["main.AW_STEP2"]
+ );
+
+ await onButtonClick(browser, "button[value='secondary_button']");
+ const { callCount } = aboutWelcomeActor.onContentMessage;
+
+ let actionCall;
+ let eventCall;
+ for (let i = 0; i < callCount; i++) {
+ const call = aboutWelcomeActor.onContentMessage.getCall(i);
+ info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`);
+ if (call.calledWithMatch("SPECIAL")) {
+ actionCall = call;
+ } else if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) {
+ eventCall = call;
+ }
+ }
+
+ Assert.equal(
+ actionCall.args[0],
+ "AWPage:SPECIAL_ACTION",
+ "Got call to handle special action"
+ );
+ Assert.equal(
+ actionCall.args[1].type,
+ "SHOW_MIGRATION_WIZARD",
+ "Special action SHOW_MIGRATION_WIZARD event handled"
+ );
+ Assert.equal(
+ actionCall.args[1].data.source,
+ "chrome",
+ "Source passed to event handler"
+ );
+ Assert.equal(
+ eventCall.args[0],
+ "AWPage:TELEMETRY_EVENT",
+ "Got call to handle Telemetry event"
+ );
+});
+
+add_task(async function test_updatesPrefOnAWOpen() {
+ Services.prefs.setBoolPref(DID_SEE_ABOUT_WELCOME_PREF, false);
+ await setAboutWelcomePref(true);
+
+ await openAboutWelcome();
+ await TestUtils.waitForCondition(
+ () =>
+ Services.prefs.getBoolPref(DID_SEE_ABOUT_WELCOME_PREF, false) === true,
+ "Updated pref to seen AW"
+ );
+ Services.prefs.clearUserPref(DID_SEE_ABOUT_WELCOME_PREF);
+});
+
+add_setup(async function () {
+ const sandbox = sinon.createSandbox();
+ // This needs to happen before any about:welcome page opens
+ sandbox.stub(FxAccounts.config, "promiseMetricsFlowURI").resolves("");
+ await setAboutWelcomeMultiStage("");
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
+
+add_task(async function test_FxA_metricsFlowURI() {
+ let browser = await openAboutWelcome();
+
+ await ContentTask.spawn(browser, {}, async () => {
+ Assert.ok(
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("div.onboardingContainer"),
+ "Wait for about:welcome to load"
+ ),
+ "about:welcome loaded"
+ );
+ });
+
+ Assert.ok(FxAccounts.config.promiseMetricsFlowURI.called, "Stub was called");
+ Assert.equal(
+ FxAccounts.config.promiseMetricsFlowURI.firstCall.args[0],
+ "aboutwelcome",
+ "Called by AboutWelcomeParent"
+ );
+
+ SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_send_aboutwelcome_as_page_in_event_telemetry() {
+ const sandbox = sinon.createSandbox();
+ let browser = await openAboutWelcome();
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+
+ await onButtonClick(browser, "button.primary");
+
+ const { callCount } = aboutWelcomeActor.onContentMessage;
+ ok(callCount >= 1, `${callCount} Stub was called`);
+
+ let eventCall;
+ for (let i = 0; i < callCount; i++) {
+ const call = aboutWelcomeActor.onContentMessage.getCall(i);
+ info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`);
+ if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) {
+ eventCall = call;
+ }
+ }
+
+ Assert.equal(
+ eventCall.args[1].event,
+ "CLICK_BUTTON",
+ "Event telemetry sent on primary button press"
+ );
+ Assert.equal(
+ eventCall.args[1].event_context.page,
+ "about:welcome",
+ "Event context page set to 'about:welcome' in event telemetry"
+ );
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_experimentAPI.js b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_experimentAPI.js
new file mode 100644
index 0000000000..fea1ca961a
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_experimentAPI.js
@@ -0,0 +1,597 @@
+"use strict";
+
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const TEST_PROTON_CONTENT = [
+ {
+ id: "AW_STEP1",
+ content: {
+ title: "Step 1",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ secondary_button_top: {
+ label: "link top",
+ action: {
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "test" },
+ },
+ },
+ help_text: {
+ text: "Here's some sample help text",
+ },
+ has_noodles: true,
+ },
+ },
+ {
+ id: "AW_STEP2",
+ content: {
+ title: "Step 2",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ has_noodles: true,
+ },
+ },
+ {
+ id: "AW_STEP3",
+ content: {
+ title: "Step 3",
+ tiles: {
+ type: "theme",
+ action: {
+ theme: "<event>",
+ },
+ data: [
+ {
+ theme: "automatic",
+ label: "theme-1",
+ tooltip: "test-tooltip",
+ },
+ {
+ theme: "dark",
+ label: "theme-2",
+ },
+ ],
+ },
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "Import",
+ action: {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: { source: "chrome" },
+ },
+ },
+ has_noodles: true,
+ },
+ },
+ {
+ id: "AW_STEP4",
+ content: {
+ title: "Step 4",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ has_noodles: true,
+ },
+ },
+];
+
+/**
+ * Test the zero onboarding using ExperimentAPI
+ */
+add_task(async function test_multistage_zeroOnboarding_experimentAPI() {
+ await setAboutWelcomePref(true);
+ await ExperimentAPI.ready();
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: { enabled: false },
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ const browser = tab.linkedBrowser;
+
+ await test_screen_content(
+ browser,
+ "Opens new tab",
+ // Expected selectors:
+ ["div.search-wrapper", "body.activity-stream"],
+ // Unexpected selectors:
+ ["div.onboardingContainer", "main.AW_STEP1"]
+ );
+
+ await doExperimentCleanup();
+});
+
+/**
+ * Test the multistage welcome UI with test content theme as first screen
+ */
+add_task(async function test_multistage_aboutwelcome_experimentAPI() {
+ const TEST_CONTENT = [
+ {
+ id: "AW_STEP1",
+ content: {
+ title: "Step 1",
+ tiles: {
+ type: "theme",
+ action: {
+ theme: "<event>",
+ },
+ data: [
+ {
+ theme: "automatic",
+ label: "theme-1",
+ tooltip: "test-tooltip",
+ },
+ {
+ theme: "dark",
+ label: "theme-2",
+ },
+ ],
+ },
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ secondary_button_top: {
+ label: "link top",
+ action: {
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: { entrypoint: "test" },
+ },
+ },
+ has_noodles: true,
+ },
+ },
+ {
+ id: "AW_STEP2",
+ content: {
+ zap: true,
+ title: "Step 2 test",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "link",
+ },
+ has_noodles: true,
+ },
+ },
+ {
+ id: "AW_STEP3",
+ content: {
+ logo: {},
+ title: "Step 3",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "Import",
+ action: {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: { source: "chrome" },
+ },
+ },
+ has_noodles: true,
+ },
+ },
+ ];
+ const sandbox = sinon.createSandbox();
+ NimbusFeatures.aboutwelcome._didSendExposureEvent = false;
+ await setAboutWelcomePref(true);
+ await ExperimentAPI.ready();
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ enabled: true,
+ value: {
+ id: "my-mochitest-experiment",
+ screens: TEST_CONTENT,
+ },
+ });
+
+ sandbox.spy(ExperimentAPI, "recordExposureEvent");
+
+ Services.telemetry.clearScalars();
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+
+ const browser = tab.linkedBrowser;
+
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ // Stub AboutWelcomeParent Content Message Handler
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ sandbox.restore();
+ });
+
+ // Test first (theme) screen for non-win7.
+ if (!win7Content) {
+ await test_screen_content(
+ browser,
+ "multistage step 1",
+ // Expected selectors:
+ [
+ "div.onboardingContainer",
+ "main.AW_STEP1",
+ "div.secondary-cta",
+ "div.secondary-cta.top",
+ "button[value='secondary_button']",
+ "button[value='secondary_button_top']",
+ "label.theme",
+ "input[type='radio']",
+ ],
+ // Unexpected selectors:
+ ["main.AW_STEP2", "main.AW_STEP3", "div.tiles-container.info"]
+ );
+
+ await onButtonClick(browser, "button.primary");
+
+ const { callCount } = aboutWelcomeActor.onContentMessage;
+ ok(callCount >= 1, `${callCount} Stub was called`);
+ let clickCall;
+ for (let i = 0; i < callCount; i++) {
+ const call = aboutWelcomeActor.onContentMessage.getCall(i);
+ info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`);
+ if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) {
+ clickCall = call;
+ }
+ }
+
+ Assert.equal(
+ clickCall.args[0],
+ "AWPage:TELEMETRY_EVENT",
+ "send telemetry event"
+ );
+
+ Assert.equal(
+ clickCall.args[1].message_id,
+ "MY-MOCHITEST-EXPERIMENT_0_AW_STEP1",
+ "Telemetry should join id defined in feature value with screen"
+ );
+ }
+
+ await test_screen_content(
+ browser,
+ "multistage step 2",
+ // Expected selectors:
+ [
+ "div.onboardingContainer",
+ "main.AW_STEP2",
+ "button[value='secondary_button']",
+ ],
+ // Unexpected selectors:
+ ["main.AW_STEP1", "main.AW_STEP3", "div.secondary-cta.top"]
+ );
+ await onButtonClick(browser, "button.primary");
+ await test_screen_content(
+ browser,
+ "multistage step 3",
+ // Expected selectors:
+ [
+ "div.onboardingContainer",
+ "main.AW_STEP3",
+ "img.brand-logo",
+ "div.welcome-text",
+ ],
+ // Unexpected selectors:
+ ["main.AW_STEP1", "main.AW_STEP2"]
+ );
+ await onButtonClick(browser, "button.primary");
+ await test_screen_content(
+ browser,
+ "home",
+ // Expected selectors:
+ ["body.activity-stream"],
+ // Unexpected selectors:
+ ["div.onboardingContainer"]
+ );
+
+ Assert.equal(
+ ExperimentAPI.recordExposureEvent.callCount,
+ 1,
+ "Called only once for exposure event"
+ );
+
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ "telemetry.event_counts",
+ "normandy#expose#nimbus_experiment",
+ 1
+ );
+
+ await doExperimentCleanup();
+});
+
+/**
+ * Test the multistage proton welcome UI using ExperimentAPI with transitions
+ */
+add_task(async function test_multistage_aboutwelcome_transitions() {
+ const sandbox = sinon.createSandbox();
+ await setAboutWelcomePref(true);
+ await ExperimentAPI.ready();
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ id: "my-mochitest-experiment",
+ enabled: true,
+ screens: TEST_PROTON_CONTENT,
+ transitions: true,
+ },
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+
+ const browser = tab.linkedBrowser;
+
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ // Stub AboutWelcomeParent Content Message Handler
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ sandbox.restore();
+ });
+
+ await test_screen_content(
+ browser,
+ "multistage proton step 1",
+ // Expected selectors:
+ ["div.proton.transition- .screen"],
+ // Unexpected selectors:
+ ["div.proton.transition-out"]
+ );
+
+ // Double click should still only transition once.
+ await onButtonClick(browser, "button.primary");
+ await onButtonClick(browser, "button.primary");
+
+ await test_screen_content(
+ browser,
+ "multistage proton step 1 transition to 2",
+ // Expected selectors:
+ ["div.proton.transition-out .screen", "div.proton.transition- .screen-1"]
+ );
+
+ await doExperimentCleanup();
+});
+
+/**
+ * Test the multistage proton welcome UI using ExperimentAPI without transitions
+ */
+add_task(async function test_multistage_aboutwelcome_transitions_off() {
+ const sandbox = sinon.createSandbox();
+ await setAboutWelcomePref(true);
+ await ExperimentAPI.ready();
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ id: "my-mochitest-experiment",
+ enabled: true,
+ screens: TEST_PROTON_CONTENT,
+ transitions: false,
+ },
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+
+ const browser = tab.linkedBrowser;
+
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ // Stub AboutWelcomeParent Content Message Handler
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ sandbox.restore();
+ });
+
+ await test_screen_content(
+ browser,
+ "multistage proton step 1",
+ // Expected selectors:
+ ["div.proton.transition- .screen"],
+ // Unexpected selectors:
+ ["div.proton.transition-out"]
+ );
+
+ await onButtonClick(browser, "button.primary");
+ await test_screen_content(
+ browser,
+ "multistage proton step 1 no transition to 2",
+ // Expected selectors:
+ [],
+ // Unexpected selectors:
+ ["div.proton.transition-out .screen-0"]
+ );
+
+ await doExperimentCleanup();
+});
+
+/* Test multistage custom backdrop
+ */
+add_task(async function test_multistage_aboutwelcome_backdrop() {
+ const sandbox = sinon.createSandbox();
+ const TEST_BACKDROP = "blue";
+
+ const TEST_CONTENT = [
+ {
+ id: "TEST_SCREEN",
+ content: {
+ position: "split",
+ logo: {},
+ title: "test",
+ },
+ },
+ ];
+ await setAboutWelcomePref(true);
+ await ExperimentAPI.ready();
+ await pushPrefs(["browser.aboutwelcome.backdrop", TEST_BACKDROP]);
+
+ const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ id: "my-mochitest-experiment",
+ screens: TEST_CONTENT,
+ },
+ });
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+
+ const browser = tab.linkedBrowser;
+
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ sandbox.restore();
+ });
+
+ await test_screen_content(
+ browser,
+ "multistage step 1",
+ // Expected selectors:
+ [`div.outer-wrapper.onboardingContainer[style*='${TEST_BACKDROP}']`]
+ );
+
+ await doExperimentCleanup();
+});
+
+add_task(async function test_multistage_aboutwelcome_utm_term() {
+ const sandbox = sinon.createSandbox();
+
+ const TEST_CONTENT = [
+ {
+ id: "TEST_SCREEN",
+ content: {
+ position: "split",
+ logo: {},
+ title: "test",
+ secondary_button_top: {
+ label: "test",
+ style: "link",
+ action: {
+ type: "OPEN_URL",
+ data: {
+ args: "https://www.mozilla.org/",
+ },
+ },
+ },
+ },
+ },
+ ];
+ await setAboutWelcomePref(true);
+ await ExperimentAPI.ready();
+
+ const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: {
+ id: "my-mochitest-experiment",
+ screens: TEST_CONTENT,
+ UTMTerm: "test",
+ },
+ });
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+
+ const browser = tab.linkedBrowser;
+ const aboutWelcomeActor = await getAboutWelcomeParent(browser);
+
+ sandbox.stub(aboutWelcomeActor, "onContentMessage");
+
+ await onButtonClick(browser, "button[value='secondary_button_top']");
+
+ let actionCall;
+
+ const { callCount } = aboutWelcomeActor.onContentMessage;
+ for (let i = 0; i < callCount; i++) {
+ const call = aboutWelcomeActor.onContentMessage.getCall(i);
+ info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`);
+ if (call.calledWithMatch("SPECIAL")) {
+ actionCall = call;
+ }
+ }
+
+ Assert.equal(
+ actionCall.args[1].data.args,
+ "https://www.mozilla.org/?utm_source=activity-stream&utm_campaign=firstrun&utm_medium=referral&utm_term=test-screen",
+ "UTMTerm set in mobile"
+ );
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ await doExperimentCleanup();
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_languageSwitcher.js b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_languageSwitcher.js
new file mode 100644
index 0000000000..55fab7ff00
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_languageSwitcher.js
@@ -0,0 +1,705 @@
+"use strict";
+
+const { getAddonAndLocalAPIsMocker } = ChromeUtils.importESModule(
+ "resource://testing-common/LangPackMatcherTestUtils.sys.mjs"
+);
+
+const { AWScreenUtils } = ChromeUtils.import(
+ "resource://activity-stream/lib/AWScreenUtils.jsm"
+);
+
+const sandbox = sinon.createSandbox();
+const mockAddonAndLocaleAPIs = getAddonAndLocalAPIsMocker(this, sandbox);
+add_task(function initSandbox() {
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
+
+/**
+ * Spy specifically on the button click telemetry.
+ *
+ * The returned function flushes the spy of all of the matching button click events, and
+ * returns the events.
+ * @returns {() => TelemetryEvents[]}
+ */
+async function spyOnTelemetryButtonClicks(browser) {
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+ return () => {
+ const result = aboutWelcomeActor.onContentMessage
+ .getCalls()
+ .filter(
+ call =>
+ call.args[0] === "AWPage:TELEMETRY_EVENT" &&
+ call.args[1]?.event === "CLICK_BUTTON"
+ )
+ // The second argument is the telemetry event.
+ .map(call => call.args[1]);
+
+ aboutWelcomeActor.onContentMessage.resetHistory();
+ return result;
+ };
+}
+
+async function openAboutWelcome() {
+ await pushPrefs(
+ // Speed up the tests by disabling transitions.
+ ["browser.aboutwelcome.transitions", false],
+ ["intl.multilingual.aboutWelcome.languageMismatchEnabled", true]
+ );
+ await setAboutWelcomePref(true);
+
+ // Stub out the doesAppNeedPin to false so the about:welcome pages do not attempt
+ // to pin the app.
+ const { ShellService } = ChromeUtils.importESModule(
+ "resource:///modules/ShellService.sys.mjs"
+ );
+ sandbox.stub(ShellService, "doesAppNeedPin").returns(false);
+
+ sandbox
+ .stub(AWScreenUtils, "evaluateScreenTargeting")
+ .resolves(true)
+ .withArgs(
+ "os.windowsBuildNumber >= 15063 && !isDefaultBrowser && !doesAppNeedPin"
+ )
+ .resolves(false)
+ .withArgs("isDeviceMigration")
+ .resolves(false);
+
+ info("Opening about:welcome");
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ return {
+ browser: tab.linkedBrowser,
+ flushClickTelemetry: await spyOnTelemetryButtonClicks(tab.linkedBrowser),
+ };
+}
+
+async function clickVisibleButton(browser, selector) {
+ // eslint-disable-next-line no-shadow
+ await ContentTask.spawn(browser, { selector }, async ({ selector }) => {
+ function getVisibleElement() {
+ for (const el of content.document.querySelectorAll(selector)) {
+ if (el.offsetParent !== null) {
+ return el;
+ }
+ }
+ return null;
+ }
+
+ await ContentTaskUtils.waitForCondition(
+ getVisibleElement,
+ selector,
+ 200, // interval
+ 100 // maxTries
+ );
+ getVisibleElement().click();
+ });
+}
+
+/**
+ * Test that selectors are present and visible.
+ */
+async function testScreenContent(
+ browser,
+ name,
+ expectedSelectors = [],
+ unexpectedSelectors = []
+) {
+ await ContentTask.spawn(
+ browser,
+ { expectedSelectors, name, unexpectedSelectors },
+ async ({
+ expectedSelectors: expected,
+ name: experimentName,
+ unexpectedSelectors: unexpected,
+ }) => {
+ function selectorIsVisible(selector) {
+ const els = content.document.querySelectorAll(selector);
+ // The offsetParent will be null if element is hidden through "display: none;"
+ return [...els].some(el => el.offsetParent !== null);
+ }
+
+ for (let selector of expected) {
+ await ContentTaskUtils.waitForCondition(
+ () => selectorIsVisible(selector),
+ `Should render ${selector} in ${experimentName}`
+ );
+ }
+ for (let selector of unexpected) {
+ ok(
+ !selectorIsVisible(selector),
+ `Should not render ${selector} in ${experimentName}`
+ );
+ }
+ }
+ );
+}
+
+/**
+ * Report telemetry mismatches nicely.
+ */
+function eventsMatch(
+ actualEvents,
+ expectedEvents,
+ message = "Telemetry events match"
+) {
+ if (actualEvents.length !== expectedEvents.length) {
+ console.error("Events do not match");
+ console.error("Actual: ", JSON.stringify(actualEvents, null, 2));
+ console.error("Expected: ", JSON.stringify(expectedEvents, null, 2));
+ }
+ for (let i = 0; i < actualEvents.length; i++) {
+ const actualEvent = JSON.stringify(actualEvents[i], null, 2);
+ const expectedEvent = JSON.stringify(expectedEvents[i], null, 2);
+ if (actualEvent !== expectedEvent) {
+ console.error("Events do not match");
+ dump(`Actual: ${actualEvent}`);
+ dump("\n");
+ dump(`Expected: ${expectedEvent}`);
+ dump("\n");
+ }
+ ok(actualEvent === expectedEvent, message);
+ }
+}
+
+const liveLanguageSwitchSelectors = [
+ ".screen.AW_LANGUAGE_MISMATCH",
+ `[data-l10n-id*="onboarding-live-language"]`,
+ `[data-l10n-id="mr2022-onboarding-live-language-text"]`,
+];
+
+/**
+ * Accept the about:welcome offer to change the Firefox language when
+ * there is a mismatch between the operating system language and the Firefox
+ * language.
+ */
+add_task(async function test_aboutwelcome_languageSwitcher_accept() {
+ sandbox.restore();
+ const { resolveLangPacks, resolveInstaller, mockable } =
+ mockAddonAndLocaleAPIs({
+ systemLocale: "es-ES",
+ appLocale: "en-US",
+ });
+
+ const { browser, flushClickTelemetry } = await openAboutWelcome();
+ await testScreenContent(
+ browser,
+ "First Screen primary CTA loaded",
+ // Expected selectors:
+ [`button.primary[value="primary_button"]`],
+ // Unexpected selectors:
+ []
+ );
+
+ info("Clicking the primary button to start the onboarding process.");
+ await clickVisibleButton(browser, `button.primary[value="primary_button"]`);
+
+ await testScreenContent(
+ browser,
+ "Live language switching (waiting for languages)",
+ // Expected selectors:
+ [
+ ...liveLanguageSwitchSelectors,
+ `[data-l10n-id="mr2022-onboarding-live-language-text"]`,
+ `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`,
+ `[data-l10n-id="mr2022-onboarding-secondary-skip-button-label"]`,
+ ],
+ // Unexpected selectors:
+ []
+ );
+
+ // Ignore the telemetry of the initial welcome screen.
+ flushClickTelemetry();
+
+ resolveLangPacks(["es-MX", "es-ES", "fr-FR"]);
+
+ await testScreenContent(
+ browser,
+ "Live language switching, asking for a language",
+ // Expected selectors:
+ [
+ ...liveLanguageSwitchSelectors,
+ `button.primary[value="primary_button"]`,
+ `button.primary[value="decline"]`,
+ ],
+ // Unexpected selectors:
+ [
+ `button[disabled] [data-l10n-id="mr2022-onboarding-live-language-waiting-button"]`,
+ `[data-l10n-id="mr2022-onboarding-secondary-skip-button-label"]`,
+ ]
+ );
+
+ info("Clicking the primary button to view language switching page.");
+
+ await clickVisibleButton(browser, "button.primary");
+
+ await testScreenContent(
+ browser,
+ "Live language switching, waiting for langpack to download",
+ // Expected selectors:
+ [
+ ...liveLanguageSwitchSelectors,
+ `[data-l10n-id="onboarding-live-language-button-label-downloading"]`,
+ `[data-l10n-id="onboarding-live-language-secondary-cancel-download"]`,
+ ],
+ // Unexpected selectors:
+ [
+ `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`,
+ ]
+ );
+
+ eventsMatch(flushClickTelemetry(), [
+ {
+ event: "CLICK_BUTTON",
+ event_context: {
+ source: "download_langpack",
+ page: "about:welcome",
+ },
+ message_id: "MR_WELCOME_DEFAULT_1_AW_LANGUAGE_MISMATCH",
+ },
+ ]);
+
+ sinon.assert.notCalled(mockable.setRequestedAppLocales);
+
+ await resolveInstaller();
+
+ await testScreenContent(
+ browser,
+ "Language changed",
+ // Expected selectors:
+ [`.screen.AW_IMPORT_SETTINGS`],
+ // Unexpected selectors:
+ liveLanguageSwitchSelectors
+ );
+
+ info("The app locale was changed to the OS locale.");
+ sinon.assert.calledWith(mockable.setRequestedAppLocales, ["es-ES", "en-US"]);
+
+ eventsMatch(flushClickTelemetry(), [
+ {
+ event: "CLICK_BUTTON",
+ event_context: {
+ source: "download_complete",
+ page: "about:welcome",
+ },
+ message_id: "MR_WELCOME_DEFAULT_1_AW_LANGUAGE_MISMATCH",
+ },
+ ]);
+});
+
+/**
+ * Test declining the about:welcome offer to change the Firefox language when
+ * there is a mismatch between the operating system language and the Firefox
+ * language.
+ */
+add_task(async function test_aboutwelcome_languageSwitcher_decline() {
+ sandbox.restore();
+ const { resolveLangPacks, resolveInstaller, mockable } =
+ mockAddonAndLocaleAPIs({
+ systemLocale: "es-ES",
+ appLocale: "en-US",
+ });
+
+ const { browser, flushClickTelemetry } = await openAboutWelcome();
+ await testScreenContent(
+ browser,
+ "First Screen primary CTA loaded",
+ // Expected selectors:
+ [`button.primary[value="primary_button"]`],
+ // Unexpected selectors:
+ []
+ );
+
+ info("Clicking the primary button to view language switching page.");
+ await clickVisibleButton(browser, `button.primary[value="primary_button"]`);
+
+ await testScreenContent(
+ browser,
+ "Live language switching (waiting for languages)",
+ // Expected selectors:
+ [
+ ...liveLanguageSwitchSelectors,
+ `[data-l10n-id="mr2022-onboarding-live-language-text"]`,
+ `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`,
+ `[data-l10n-id="mr2022-onboarding-secondary-skip-button-label"]`,
+ ],
+ // Unexpected selectors:
+ []
+ );
+
+ // Ignore the telemetry of the initial welcome screen.
+ flushClickTelemetry();
+
+ resolveLangPacks(["es-MX", "es-ES", "fr-FR"]);
+ resolveInstaller();
+
+ await testScreenContent(
+ browser,
+ "Live language switching, asking for a language",
+ // Expected selectors:
+ [
+ ...liveLanguageSwitchSelectors,
+ `button.primary[value="primary_button"]`,
+ `button.primary[value="decline"]`,
+ ],
+ // Unexpected selectors:
+ [
+ `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`,
+ `[data-l10n-id="mr2022-onboarding-secondary-skip-button-label"]`,
+ ]
+ );
+
+ sinon.assert.notCalled(mockable.setRequestedAppLocales);
+
+ info("Clicking the secondary button to skip installing the langpack.");
+ await clickVisibleButton(browser, `button.primary[value="decline"]`);
+
+ await testScreenContent(
+ browser,
+ "Language selection declined",
+ // Expected selectors:
+ [`.screen.AW_IMPORT_SETTINGS`],
+ // Unexpected selectors:
+ liveLanguageSwitchSelectors
+ );
+
+ info("The requested locale should be set to the original en-US");
+ sinon.assert.calledWith(mockable.setRequestedAppLocales, ["en-US"]);
+
+ eventsMatch(flushClickTelemetry(), [
+ {
+ event: "CLICK_BUTTON",
+ event_context: {
+ source: "decline",
+ page: "about:welcome",
+ },
+ message_id: "MR_WELCOME_DEFAULT_1_AW_LANGUAGE_MISMATCH",
+ },
+ ]);
+});
+
+/**
+ * Ensure the langpack can be installed before the user gets to the language screen.
+ */
+add_task(async function test_aboutwelcome_languageSwitcher_asyncCalls() {
+ sandbox.restore();
+ const { resolveLangPacks, resolveInstaller, mockable } =
+ mockAddonAndLocaleAPIs({
+ systemLocale: "es-ES",
+ appLocale: "en-US",
+ });
+
+ await openAboutWelcome();
+
+ info("Waiting for getAvailableLangpacks to be called.");
+ await TestUtils.waitForCondition(
+ () => mockable.getAvailableLangpacks.called,
+ "getAvailableLangpacks called once"
+ );
+ ok(mockable.installLangPack.notCalled);
+
+ resolveLangPacks(["es-MX", "es-ES", "fr-FR"]);
+
+ await TestUtils.waitForCondition(
+ () => mockable.installLangPack.called,
+ "installLangPack was called once"
+ );
+ ok(mockable.getAvailableLangpacks.called);
+
+ resolveInstaller();
+});
+
+/**
+ * Test that the "en-US" langpack is installed, if it's already available as the last
+ * fallback locale.
+ */
+add_task(async function test_aboutwelcome_fallback_locale() {
+ sandbox.restore();
+ const { resolveLangPacks, resolveInstaller, mockable } =
+ mockAddonAndLocaleAPIs({
+ systemLocale: "en-US",
+ appLocale: "it",
+ });
+
+ await openAboutWelcome();
+
+ info("Waiting for getAvailableLangpacks to be called.");
+ await TestUtils.waitForCondition(
+ () => mockable.getAvailableLangpacks.called,
+ "getAvailableLangpacks called once"
+ );
+ ok(mockable.installLangPack.notCalled);
+
+ resolveLangPacks(["en-US"]);
+
+ await TestUtils.waitForCondition(
+ () => mockable.installLangPack.called,
+ "installLangPack was called once"
+ );
+ ok(mockable.getAvailableLangpacks.called);
+
+ resolveInstaller();
+});
+
+/**
+ * Test when AMO does not have a matching language.
+ */
+add_task(async function test_aboutwelcome_languageSwitcher_noMatch() {
+ sandbox.restore();
+ const { resolveLangPacks, mockable } = mockAddonAndLocaleAPIs({
+ systemLocale: "tlh", // Klingon
+ appLocale: "en-US",
+ });
+
+ const { browser } = await openAboutWelcome();
+
+ info("Clicking the primary button to start installing the langpack.");
+ await clickVisibleButton(browser, `button.primary[value="primary_button"]`);
+
+ // Klingon is not supported.
+ resolveLangPacks(["es-MX", "es-ES", "fr-FR"]);
+
+ await testScreenContent(
+ browser,
+ "Language selection skipped",
+ // Expected selectors:
+ [`.screen.AW_IMPORT_SETTINGS`],
+ // Unexpected selectors:
+ [
+ `[data-l10n-id*="onboarding-live-language"]`,
+ `[data-l10n-id="onboarding-live-language-header"]`,
+ ]
+ );
+ sinon.assert.notCalled(mockable.setRequestedAppLocales);
+});
+
+/**
+ * Test when bidi live reloading is not supported.
+ */
+add_task(async function test_aboutwelcome_languageSwitcher_bidiNotSupported() {
+ sandbox.restore();
+ await pushPrefs(["intl.multilingual.liveReloadBidirectional", false]);
+
+ const { mockable } = mockAddonAndLocaleAPIs({
+ systemLocale: "ar-EG", // Arabic (Egypt)
+ appLocale: "en-US",
+ });
+
+ const { browser } = await openAboutWelcome();
+
+ info("Clicking the primary button to start installing the langpack.");
+ await clickVisibleButton(browser, `button.primary[value="primary_button"]`);
+
+ await testScreenContent(
+ browser,
+ "Language selection skipped for bidi",
+ // Expected selectors:
+ [`.screen.AW_IMPORT_SETTINGS`],
+ // Unexpected selectors:
+ [
+ `[data-l10n-id*="onboarding-live-language"]`,
+ `[data-l10n-id="onboarding-live-language-header"]`,
+ ]
+ );
+
+ sinon.assert.notCalled(mockable.setRequestedAppLocales);
+});
+
+/**
+ * Test when bidi live reloading is not supported and no langpacks.
+ */
+add_task(
+ async function test_aboutwelcome_languageSwitcher_bidiNotSupported_noLangPacks() {
+ sandbox.restore();
+ await pushPrefs(["intl.multilingual.liveReloadBidirectional", false]);
+
+ const { resolveLangPacks, mockable } = mockAddonAndLocaleAPIs({
+ systemLocale: "ar-EG", // Arabic (Egypt)
+ appLocale: "en-US",
+ });
+ resolveLangPacks([]);
+
+ const { browser } = await openAboutWelcome();
+
+ info("Clicking the primary button to start installing the langpack.");
+ await clickVisibleButton(browser, `button.primary[value="primary_button"]`);
+
+ await testScreenContent(
+ browser,
+ "Language selection skipped for bidi",
+ // Expected selectors:
+ [`.screen.AW_IMPORT_SETTINGS`],
+ // Unexpected selectors:
+ [
+ `[data-l10n-id*="onboarding-live-language"]`,
+ `[data-l10n-id="onboarding-live-language-header"]`,
+ ]
+ );
+
+ sinon.assert.notCalled(mockable.setRequestedAppLocales);
+ }
+);
+
+/**
+ * Test when bidi live reloading is supported.
+ */
+add_task(async function test_aboutwelcome_languageSwitcher_bidiNotSupported() {
+ sandbox.restore();
+ await pushPrefs(["intl.multilingual.liveReloadBidirectional", true]);
+
+ const { resolveLangPacks, mockable } = mockAddonAndLocaleAPIs({
+ systemLocale: "ar-EG", // Arabic (Egypt)
+ appLocale: "en-US",
+ });
+
+ const { browser } = await openAboutWelcome();
+
+ info("Clicking the primary button to start installing the langpack.");
+ await clickVisibleButton(browser, `button.primary[value="primary_button"]`);
+
+ resolveLangPacks(["ar-EG", "es-ES", "fr-FR"]);
+
+ await testScreenContent(
+ browser,
+ "Live language switching with bidi supported",
+ // Expected selectors:
+ [...liveLanguageSwitchSelectors],
+ // Unexpected selectors:
+ []
+ );
+
+ sinon.assert.notCalled(mockable.setRequestedAppLocales);
+});
+
+/**
+ * Test hitting the cancel button when waiting on a langpack.
+ */
+add_task(async function test_aboutwelcome_languageSwitcher_cancelWaiting() {
+ sandbox.restore();
+ const { resolveLangPacks, resolveInstaller, mockable } =
+ mockAddonAndLocaleAPIs({
+ systemLocale: "es-ES",
+ appLocale: "en-US",
+ });
+
+ const { browser, flushClickTelemetry } = await openAboutWelcome();
+
+ info("Clicking the primary button to start the onboarding process.");
+ await clickVisibleButton(browser, `button.primary[value="primary_button"]`);
+ resolveLangPacks(["es-MX", "es-ES", "fr-FR"]);
+
+ await testScreenContent(
+ browser,
+ "Live language switching, asking for a language",
+ // Expected selectors:
+ liveLanguageSwitchSelectors,
+ // Unexpected selectors:
+ []
+ );
+
+ info("Clicking the primary button to view language switching page.");
+ await clickVisibleButton(browser, `button.primary[value="primary_button"]`);
+
+ await testScreenContent(
+ browser,
+ "Live language switching, waiting for langpack to download",
+ // Expected selectors:
+ [
+ ...liveLanguageSwitchSelectors,
+ `[data-l10n-id="onboarding-live-language-button-label-downloading"]`,
+ `[data-l10n-id="onboarding-live-language-secondary-cancel-download"]`,
+ ],
+ // Unexpected selectors:
+ [
+ `button[disabled] [data-l10n-id="onboarding-live-language-waiting-button"]`,
+ ]
+ );
+
+ // Ignore all the telemetry up to this point.
+ flushClickTelemetry();
+
+ info("Cancel the request for the language");
+ await clickVisibleButton(browser, "button.secondary");
+
+ await testScreenContent(
+ browser,
+ "Language selection declined waiting",
+ // Expected selectors:
+ [`.screen.AW_IMPORT_SETTINGS`],
+ // Unexpected selectors:
+ liveLanguageSwitchSelectors
+ );
+
+ eventsMatch(flushClickTelemetry(), [
+ {
+ event: "CLICK_BUTTON",
+ event_context: {
+ source: "cancel_waiting",
+ page: "about:welcome",
+ },
+ message_id: "MR_WELCOME_DEFAULT_1_AW_LANGUAGE_MISMATCH",
+ },
+ ]);
+
+ await resolveInstaller();
+
+ is(flushClickTelemetry().length, 0);
+ sinon.assert.notCalled(mockable.setRequestedAppLocales);
+});
+
+/**
+ * Test MR About Welcome language mismatch screen
+ */
+add_task(async function test_aboutwelcome_languageSwitcher_MR() {
+ sandbox.restore();
+
+ const { resolveLangPacks, resolveInstaller } = mockAddonAndLocaleAPIs({
+ systemLocale: "es-ES",
+ appLocale: "en-US",
+ });
+
+ const { browser } = await openAboutWelcome(true);
+
+ info("Clicking the primary button to view language switching screen.");
+ await clickVisibleButton(browser, `button.primary[value="primary_button"]`);
+
+ resolveLangPacks(["es-AR"]);
+ await testScreenContent(
+ browser,
+ "Live language switching, asking for a language",
+ // Expected selectors:
+ [
+ `#mainContentHeader[data-l10n-id="mr2022-onboarding-live-language-text"]`,
+ `[data-l10n-id="mr2022-language-mismatch-subtitle"]`,
+ `.section-secondary [data-l10n-id="mr2022-onboarding-live-language-text"]`,
+ `[data-l10n-id="mr2022-onboarding-live-language-switch-to"]`,
+ `button.primary[value="primary_button"]`,
+ `button.primary[value="decline"]`,
+ ],
+ // Unexpected selectors:
+ [`[data-l10n-id="onboarding-live-language-header"]`]
+ );
+
+ await resolveInstaller();
+ await testScreenContent(
+ browser,
+ "Switched some to langpack (raw) strings after install",
+ // Expected selectors:
+ [`#mainContentHeader[data-l10n-id="mr2022-onboarding-live-language-text"]`],
+ // Unexpected selectors:
+ [
+ `.section-secondary [data-l10n-id="mr2022-onboarding-live-language-text"]`,
+ `[data-l10n-id="mr2022-onboarding-live-language-switch-to"]`,
+ ]
+ );
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_mr.js b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_mr.js
new file mode 100644
index 0000000000..145d157e1a
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_mr.js
@@ -0,0 +1,621 @@
+"use strict";
+
+const { AboutWelcomeParent } = ChromeUtils.import(
+ "resource:///actors/AboutWelcomeParent.jsm"
+);
+
+const { AboutWelcomeTelemetry } = ChromeUtils.import(
+ "resource://activity-stream/aboutwelcome/lib/AboutWelcomeTelemetry.jsm"
+);
+const { AWScreenUtils } = ChromeUtils.import(
+ "resource://activity-stream/lib/AWScreenUtils.jsm"
+);
+const { InternalTestingProfileMigrator } = ChromeUtils.importESModule(
+ "resource:///modules/InternalTestingProfileMigrator.sys.mjs"
+);
+
+async function clickVisibleButton(browser, selector) {
+ // eslint-disable-next-line no-shadow
+ await ContentTask.spawn(browser, { selector }, async ({ selector }) => {
+ function getVisibleElement() {
+ for (const el of content.document.querySelectorAll(selector)) {
+ if (el.offsetParent !== null) {
+ return el;
+ }
+ }
+ return null;
+ }
+ await ContentTaskUtils.waitForCondition(
+ getVisibleElement,
+ selector,
+ 200, // interval
+ 100 // maxTries
+ );
+ getVisibleElement().click();
+ });
+}
+
+add_setup(async function () {
+ SpecialPowers.pushPrefEnv({
+ set: [
+ ["ui.prefersReducedMotion", 1],
+ ["browser.aboutwelcome.transitions", false],
+ ],
+ });
+});
+
+function initSandbox({ pin = true, isDefault = false } = {}) {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(AboutWelcomeParent, "doesAppNeedPin").returns(pin);
+ sandbox.stub(AboutWelcomeParent, "isDefaultBrowser").returns(isDefault);
+
+ return sandbox;
+}
+
+/**
+ * Test MR message telemetry
+ */
+add_task(async function test_aboutwelcome_mr_template_telemetry() {
+ const sandbox = initSandbox();
+
+ let { browser, cleanup } = await openMRAboutWelcome();
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ // Stub AboutWelcomeParent's Content Message Handler
+ const messageStub = sandbox.spy(aboutWelcomeActor, "onContentMessage");
+ await clickVisibleButton(browser, ".action-buttons button.secondary");
+
+ const { callCount } = messageStub;
+ ok(callCount >= 1, `${callCount} Stub was called`);
+ let clickCall;
+ for (let i = 0; i < callCount; i++) {
+ const call = messageStub.getCall(i);
+ info(`Call #${i}: ${call.args[0]} ${JSON.stringify(call.args[1])}`);
+ if (call.calledWithMatch("", { event: "CLICK_BUTTON" })) {
+ clickCall = call;
+ }
+ }
+
+ Assert.ok(
+ clickCall.args[1].message_id.startsWith("MR_WELCOME_DEFAULT"),
+ "Telemetry includes MR message id"
+ );
+
+ await cleanup();
+ sandbox.restore();
+});
+
+/**
+ * Telemetry Impression with Pin as First Screen
+ */
+add_task(async function test_aboutwelcome_pin_screen_impression() {
+ await pushPrefs(["browser.shell.checkDefaultBrowser", true]);
+
+ const sandbox = initSandbox();
+ sandbox
+ .stub(AWScreenUtils, "evaluateScreenTargeting")
+ .resolves(true)
+ .withArgs(
+ "os.windowsBuildNumber >= 15063 && !isDefaultBrowser && !doesAppNeedPin"
+ )
+ .resolves(false)
+ .withArgs("isDeviceMigration")
+ .resolves(false);
+
+ let impressionSpy = sandbox.spy(
+ AboutWelcomeTelemetry.prototype,
+ "sendTelemetry"
+ );
+
+ let { browser, cleanup } = await openMRAboutWelcome();
+ // Wait for screen elements to render before checking impression pings
+ await test_screen_content(
+ browser,
+ "Onboarding screen elements rendered",
+ // Expected selectors:
+ [
+ `main.screen[pos="split"]`,
+ "div.secondary-cta.top",
+ "button[value='secondary_button_top']",
+ ]
+ );
+
+ const { callCount } = impressionSpy;
+ ok(callCount >= 1, `${callCount} impressionSpy was called`);
+ let impressionCall;
+ for (let i = 0; i < callCount; i++) {
+ const call = impressionSpy.getCall(i);
+ info(`Call #${i}: ${JSON.stringify(call.args[0])}`);
+ if (
+ call.calledWithMatch({ event: "IMPRESSION" }) &&
+ !call.calledWithMatch({ message_id: "MR_WELCOME_DEFAULT" })
+ ) {
+ info(`Screen Impression Call #${i}: ${JSON.stringify(call.args[0])}`);
+ impressionCall = call;
+ }
+ }
+
+ Assert.ok(
+ impressionCall.args[0].message_id.startsWith(
+ "MR_WELCOME_DEFAULT_0_AW_PIN_FIREFOX_P"
+ ),
+ "Impression telemetry includes correct message id"
+ );
+ await cleanup();
+ sandbox.restore();
+ await popPrefs();
+});
+
+/**
+ * Test MR template content - Browser is not Pinned and not set as default
+ */
+add_task(async function test_aboutwelcome_mr_template_content() {
+ await pushPrefs(["browser.shell.checkDefaultBrowser", true]);
+
+ const sandbox = initSandbox();
+
+ sandbox
+ .stub(AWScreenUtils, "evaluateScreenTargeting")
+ .resolves(true)
+ .withArgs(
+ "os.windowsBuildNumber >= 15063 && !isDefaultBrowser && !doesAppNeedPin"
+ )
+ .resolves(false)
+ .withArgs("isDeviceMigration")
+ .resolves(false);
+
+ let { cleanup, browser } = await openMRAboutWelcome();
+
+ await test_screen_content(
+ browser,
+ "MR template includes screens with split position and a sign in link on the first screen",
+ // Expected selectors:
+ [
+ `main.screen[pos="split"]`,
+ "div.secondary-cta.top",
+ "button[value='secondary_button_top']",
+ ]
+ );
+
+ await test_screen_content(
+ browser,
+ "renders pin screen",
+ //Expected selectors:
+ ["main.AW_PIN_FIREFOX"],
+ //Unexpected selectors:
+ ["main.AW_GRATITUDE"]
+ );
+
+ await clickVisibleButton(browser, ".action-buttons button.secondary");
+
+ //should render set default
+ await test_screen_content(
+ browser,
+ "renders set default screen",
+ //Expected selectors:
+ ["main.AW_SET_DEFAULT"],
+ //Unexpected selectors:
+ ["main.AW_CHOOSE_THEME"]
+ );
+
+ await cleanup();
+ sandbox.restore();
+ await popPrefs();
+});
+
+/**
+ * Test MR template content - Browser has been set as Default, not pinned
+ */
+add_task(async function test_aboutwelcome_mr_template_content_pin() {
+ await pushPrefs(["browser.shell.checkDefaultBrowser", true]);
+
+ const sandbox = initSandbox({ isDefault: true });
+
+ sandbox
+ .stub(AWScreenUtils, "evaluateScreenTargeting")
+ .resolves(true)
+ .withArgs(
+ "os.windowsBuildNumber >= 15063 && !isDefaultBrowser && !doesAppNeedPin"
+ )
+ .resolves(false)
+ .withArgs("isDeviceMigration")
+ .resolves(false);
+
+ let { browser, cleanup } = await openMRAboutWelcome();
+
+ await test_screen_content(
+ browser,
+ "renders pin screen",
+ //Expected selectors:
+ ["main.AW_PIN_FIREFOX"],
+ //Unexpected selectors:
+ ["main.AW_SET_DEFAULT"]
+ );
+
+ await clickVisibleButton(browser, ".action-buttons button.secondary");
+
+ await test_screen_content(
+ browser,
+ "renders next screen",
+ //Expected selectors:
+ ["main"],
+ //Unexpected selectors:
+ ["main.AW_SET_DEFAULT"]
+ );
+
+ await cleanup();
+ sandbox.restore();
+ await popPrefs();
+});
+
+/**
+ * Test MR template content - Browser is Pinned, not default
+ */
+add_task(async function test_aboutwelcome_mr_template_only_default() {
+ await pushPrefs(["browser.shell.checkDefaultBrowser", true]);
+
+ const sandbox = initSandbox({ pin: false });
+ sandbox
+ .stub(AWScreenUtils, "evaluateScreenTargeting")
+ .resolves(true)
+ .withArgs(
+ "os.windowsBuildNumber >= 15063 && !isDefaultBrowser && !doesAppNeedPin"
+ )
+ .resolves(false)
+ .withArgs("isDeviceMigration")
+ .resolves(false);
+
+ let { browser, cleanup } = await openMRAboutWelcome();
+ //should render set default
+ await test_screen_content(
+ browser,
+ "renders set default screen",
+ //Expected selectors:
+ ["main.AW_ONLY_DEFAULT"],
+ //Unexpected selectors:
+ ["main.AW_PIN_FIREFOX"]
+ );
+
+ await cleanup();
+ sandbox.restore();
+ await popPrefs();
+});
+/**
+ * Test MR template content - Browser is Pinned and set as default
+ */
+add_task(async function test_aboutwelcome_mr_template_get_started() {
+ await pushPrefs(["browser.shell.checkDefaultBrowser", true]);
+
+ const sandbox = initSandbox({ pin: false, isDefault: true });
+
+ sandbox
+ .stub(AWScreenUtils, "evaluateScreenTargeting")
+ .resolves(true)
+ .withArgs(
+ "os.windowsBuildNumber >= 15063 && !isDefaultBrowser && !doesAppNeedPin"
+ )
+ .resolves(false)
+ .withArgs("isDeviceMigration")
+ .resolves(false);
+
+ let { browser, cleanup } = await openMRAboutWelcome();
+
+ //should render set default
+ await test_screen_content(
+ browser,
+ "doesn't render pin and set default screens",
+ //Expected selectors:
+ ["main.AW_GET_STARTED"],
+ //Unexpected selectors:
+ ["main.AW_PIN_FIREFOX", "main.AW_ONLY_DEFAULT"]
+ );
+
+ await cleanup();
+ sandbox.restore();
+ await popPrefs();
+});
+
+add_task(async function test_aboutwelcome_gratitude() {
+ const TEST_CONTENT = [
+ {
+ id: "AW_GRATITUDE",
+ content: {
+ position: "split",
+ split_narrow_bkg_position: "-228px",
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-gratitude.svg') var(--mr-secondary-position) no-repeat, var(--mr-screen-background-color)",
+ progress_bar: true,
+ logo: {},
+ title: {
+ string_id: "mr2022-onboarding-gratitude-title",
+ },
+ subtitle: {
+ string_id: "mr2022-onboarding-gratitude-subtitle",
+ },
+ primary_button: {
+ label: {
+ string_id: "mr2022-onboarding-gratitude-primary-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ];
+ await setAboutWelcomeMultiStage(JSON.stringify(TEST_CONTENT)); // NB: calls SpecialPowers.pushPrefEnv
+ let { cleanup, browser } = await openMRAboutWelcome();
+
+ // execution
+ await test_screen_content(
+ browser,
+ "doesn't render secondary button on gratitude screen",
+ //Expected selectors
+ ["main.AW_GRATITUDE", "button[value='primary_button']"],
+
+ //Unexpected selectors:
+ ["button[value='secondary_button']"]
+ );
+ await clickVisibleButton(browser, ".action-buttons button.primary");
+
+ // make sure the button navigates to newtab
+ await test_screen_content(
+ browser,
+ "home",
+ //Expected selectors
+ ["body.activity-stream"],
+
+ //Unexpected selectors:
+ ["main.AW_GRATITUDE"]
+ );
+
+ // cleanup
+ await SpecialPowers.popPrefEnv(); // for setAboutWelcomeMultiStage
+ await cleanup();
+});
+
+add_task(async function test_aboutwelcome_embedded_migration() {
+ // Let's make sure at least one migrator is available and enabled - the
+ // InternalTestingProfileMigrator.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.migrate.internal-testing.enabled", true]],
+ });
+
+ const sandbox = sinon.createSandbox();
+ sandbox
+ .stub(InternalTestingProfileMigrator.prototype, "getResources")
+ .callsFake(() =>
+ Promise.resolve([
+ {
+ type: MigrationUtils.resourceTypes.BOOKMARKS,
+ migrate: () => {},
+ },
+ ])
+ );
+ sandbox.stub(MigrationUtils, "_importQuantities").value({
+ bookmarks: 123,
+ history: 123,
+ logins: 123,
+ });
+ const migrated = new Promise(resolve => {
+ sandbox
+ .stub(InternalTestingProfileMigrator.prototype, "migrate")
+ .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => {
+ aProgressCallback(MigrationUtils.resourceTypes.BOOKMARKS);
+ Services.obs.notifyObservers(null, "Migration:Ended");
+ resolve();
+ });
+ });
+
+ let telemetrySpy = sandbox.spy(
+ AboutWelcomeTelemetry.prototype,
+ "sendTelemetry"
+ );
+
+ const TEST_CONTENT = [
+ {
+ id: "AW_IMPORT_SETTINGS_EMBEDDED",
+ content: {
+ tiles: { type: "migration-wizard" },
+ position: "split",
+ split_narrow_bkg_position: "-42px",
+ image_alt_text: {
+ string_id: "mr2022-onboarding-import-image-alt",
+ },
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-import.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)",
+ progress_bar: true,
+ migrate_start: {
+ action: {},
+ },
+ migrate_close: {
+ action: { navigate: true },
+ },
+ secondary_button: {
+ label: {
+ string_id: "mr2022-onboarding-secondary-skip-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ has_arrow_icon: true,
+ },
+ },
+ },
+ {
+ id: "AW_STEP2",
+ content: {
+ position: "split",
+ split_narrow_bkg_position: "-228px",
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-gratitude.svg') var(--mr-secondary-position) no-repeat, var(--mr-screen-background-color)",
+ progress_bar: true,
+ logo: {},
+ title: {
+ string_id: "mr2022-onboarding-gratitude-title",
+ },
+ subtitle: {
+ string_id: "mr2022-onboarding-gratitude-subtitle",
+ },
+ primary_button: {
+ label: {
+ string_id: "mr2022-onboarding-gratitude-primary-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ];
+
+ await setAboutWelcomeMultiStage(JSON.stringify(TEST_CONTENT)); // NB: calls SpecialPowers.pushPrefEnv
+ let { cleanup, browser } = await openMRAboutWelcome();
+
+ // execution
+ await test_screen_content(
+ browser,
+ "Renders a <migration-wizard> custom element",
+ // We expect <migration-wizard> to automatically request the set of migrators
+ // upon binding to the DOM, and to not be in dialog mode.
+ [
+ "main.AW_IMPORT_SETTINGS_EMBEDDED",
+ "migration-wizard[auto-request-state]:not([dialog-mode])",
+ ]
+ );
+
+ // Do a basic test to make sure that the <migration-wizard> is on the right
+ // page and the <panel-list> can open.
+ await SpecialPowers.spawn(
+ browser,
+ [`panel-item[key="${InternalTestingProfileMigrator.key}"]`],
+ async menuitemSelector => {
+ const { MigrationWizardConstants } = ChromeUtils.importESModule(
+ "chrome://browser/content/migration/migration-wizard-constants.mjs"
+ );
+
+ let wizard = content.document.querySelector("migration-wizard");
+ await new Promise(resolve => content.requestAnimationFrame(resolve));
+ let shadow = wizard.openOrClosedShadowRoot;
+ let deck = shadow.querySelector("#wizard-deck");
+
+ // It's unlikely but possible that the deck might not yet be showing the
+ // selection page yet, in which case we wait for that page to appear.
+ if (deck.selectedViewName !== MigrationWizardConstants.PAGES.SELECTION) {
+ await ContentTaskUtils.waitForMutationCondition(
+ deck,
+ { attributeFilter: ["selected-view"] },
+ () => {
+ return (
+ deck.getAttribute("selected-view") ===
+ `page-${MigrationWizardConstants.PAGES.SELECTION}`
+ );
+ }
+ );
+ }
+
+ Assert.ok(true, "Selection page is being shown in the migration wizard.");
+
+ // Now let's make sure that the <panel-list> can appear.
+ let panelList = wizard.querySelector("panel-list");
+ Assert.ok(panelList, "Found the <panel-list>.");
+
+ // The "shown" event from the panel-list is coming from a lower level
+ // of privilege than where we're executing this SpecialPowers.spawn
+ // task. In order to properly listen for it, we have to ask
+ // ContentTaskUtils.waitForEvent to listen for untrusted events.
+ let shown = ContentTaskUtils.waitForEvent(
+ panelList,
+ "shown",
+ false /* capture */,
+ null /* checkFn */,
+ true /* wantsUntrusted */
+ );
+ let selector = shadow.querySelector("#browser-profile-selector");
+
+ // The migration wizard programmatically focuses the selector after
+ // the selection page is shown using an rAF. If we click the button
+ // before that occurs, then the focus can shift after the panel opens
+ // which will cause it to immediately close again. So we wait for the
+ // selection button to gain focus before continuing.
+ if (!selector.matches(":focus")) {
+ await ContentTaskUtils.waitForEvent(selector, "focus");
+ }
+
+ selector.click();
+ await shown;
+
+ let panelRect = panelList.getBoundingClientRect();
+ let selectorRect = selector.getBoundingClientRect();
+
+ // Recalculate the <panel-list> rect top value relative to the top-left
+ // of the selectorRect. We expect the <panel-list> to be tightly anchored
+ // to the bottom of the <button>, so we expect this new value to be close to 0,
+ // to account for subpixel rounding
+ let panelTopLeftRelativeToAnchorTopLeft =
+ panelRect.top - selectorRect.top - selectorRect.height;
+
+ function isfuzzy(actual, expected, epsilon, msg) {
+ if (actual >= expected - epsilon && actual <= expected + epsilon) {
+ ok(true, msg);
+ } else {
+ is(actual, expected, msg);
+ }
+ }
+
+ isfuzzy(
+ panelTopLeftRelativeToAnchorTopLeft,
+ 0,
+ 1,
+ "Panel should be tightly anchored to the bottom of the button shadow node."
+ );
+
+ let panelItem = wizard.querySelector(menuitemSelector);
+ panelItem.click();
+
+ let importButton = shadow.querySelector("#import");
+ importButton.click();
+ }
+ );
+
+ await migrated;
+ Assert.ok(
+ telemetrySpy.calledWithMatch({
+ event: "CLICK_BUTTON",
+ event_context: { source: "primary_button", page: "about:welcome" },
+ message_id: sinon.match.string,
+ }),
+ "Should have sent telemetry for clicking the 'Import' button."
+ );
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let wizard = content.document.querySelector("migration-wizard");
+ let shadow = wizard.openOrClosedShadowRoot;
+ let continueButton = shadow.querySelector(
+ "div[name='page-progress'] .continue-button"
+ );
+ continueButton.click();
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("main.AW_STEP2"),
+ "Waiting for step 2 to render"
+ );
+ });
+
+ Assert.ok(
+ telemetrySpy.calledWithMatch({
+ event: "CLICK_BUTTON",
+ event_context: { source: "migrate_close", page: "about:welcome" },
+ message_id: sinon.match.string,
+ }),
+ "Should have sent telemetry for clicking the 'Continue' button."
+ );
+
+ // cleanup
+ await SpecialPowers.popPrefEnv(); // for the InternalTestingProfileMigrator.
+ await SpecialPowers.popPrefEnv(); // for setAboutWelcomeMultiStage
+ await cleanup();
+ sandbox.restore();
+ let migrator = await MigrationUtils.getMigrator(
+ InternalTestingProfileMigrator.key
+ );
+ migrator.flushResourceCache();
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_video.js b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_video.js
new file mode 100644
index 0000000000..ed331e6752
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_multistage_video.js
@@ -0,0 +1,97 @@
+"use strict";
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const videoUrl =
+ "https://www.mozilla.org/tests/dom/media/webaudio/test/noaudio.webm";
+
+function testAutoplayPermission(browser) {
+ let principal = browser.contentPrincipal;
+ is(
+ PermissionTestUtils.testPermission(principal, "autoplay-media"),
+ Services.perms.ALLOW_ACTION,
+ `Autoplay is allowed on ${principal.origin}`
+ );
+}
+
+async function openAWWithVideo({
+ autoPlay = false,
+ video_url = videoUrl,
+ ...rest
+} = {}) {
+ const content = [
+ {
+ id: "VIDEO_ONBOARDING",
+ content: {
+ position: "center",
+ logo: {},
+ title: "Video onboarding",
+ secondary_button: { label: "Skip video", action: { navigate: true } },
+ video_container: {
+ video_url,
+ action: { navigate: true },
+ autoPlay,
+ ...rest,
+ },
+ },
+ },
+ ];
+ await setAboutWelcomeMultiStage(JSON.stringify(content));
+ let { cleanup, browser } = await openMRAboutWelcome();
+ return {
+ browser,
+ content,
+ async cleanup() {
+ await SpecialPowers.popPrefEnv();
+ await cleanup();
+ },
+ };
+}
+
+add_task(async function test_aboutwelcome_video_autoplay() {
+ let { cleanup, browser } = await openAWWithVideo({ autoPlay: true });
+
+ testAutoplayPermission(browser);
+
+ await SpecialPowers.spawn(browser, [videoUrl], async url => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("main.with-video"),
+ "Waiting for video onboarding screen"
+ );
+ let video = content.document.querySelector(`video[src='${url}'][autoplay]`);
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ video.currentTime > 0 &&
+ !video.paused &&
+ !video.ended &&
+ video.readyState > 2,
+ "Waiting for video to play"
+ );
+ ok(!video.error, "Video should not have an error");
+ });
+
+ await cleanup();
+});
+
+add_task(async function test_aboutwelcome_video_no_autoplay() {
+ let { cleanup, browser } = await openAWWithVideo();
+
+ testAutoplayPermission(browser);
+
+ await SpecialPowers.spawn(browser, [videoUrl], async url => {
+ let video = await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(`video[src='${url}']:not([autoplay])`),
+ "Waiting for video element to render"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => video.paused && !video.ended && video.readyState > 2,
+ "Waiting for video to be playable but not playing"
+ );
+ ok(!video.error, "Video should not have an error");
+ });
+
+ await cleanup();
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_observer.js b/browser/components/newtab/test/browser/browser_aboutwelcome_observer.js
new file mode 100644
index 0000000000..58d9b43c0e
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_observer.js
@@ -0,0 +1,71 @@
+"use strict";
+
+const { AboutWelcomeParent } = ChromeUtils.import(
+ "resource:///actors/AboutWelcomeParent.jsm"
+);
+
+async function openAboutWelcomeTab() {
+ await setAboutWelcomePref(true);
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome"
+ );
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ });
+ return tab;
+}
+
+/**
+ * Test simplified welcome UI tab closed terminate reason
+ */
+add_task(async function test_About_Welcome_Tab_Close() {
+ await setAboutWelcomePref(true);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ false
+ );
+
+ Assert.ok(Services.focus.activeWindow, "Active window is not null");
+ let AWP = new AboutWelcomeParent();
+ Assert.ok(AWP.AboutWelcomeObserver, "AboutWelcomeObserver is not null");
+
+ BrowserTestUtils.removeTab(tab);
+ Assert.equal(
+ AWP.AboutWelcomeObserver.terminateReason,
+ AWP.AboutWelcomeObserver.AWTerminate.TAB_CLOSED,
+ "Terminated due to tab closed"
+ );
+});
+
+/**
+ * Test simplified welcome UI closed due to change in location uri
+ */
+add_task(async function test_About_Welcome_Location_Change() {
+ await openAboutWelcomeTab();
+ let windowGlobalParent =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal;
+
+ let aboutWelcomeActor = await windowGlobalParent.getActor("AboutWelcome");
+
+ Assert.ok(
+ aboutWelcomeActor.AboutWelcomeObserver,
+ "AboutWelcomeObserver is not null"
+ );
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ "http://example.com/#foo"
+ );
+ await BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ "http://example.com/#foo"
+ );
+
+ Assert.equal(
+ aboutWelcomeActor.AboutWelcomeObserver.terminateReason,
+ aboutWelcomeActor.AboutWelcomeObserver.AWTerminate.ADDRESS_BAR_NAVIGATED,
+ "Terminated due to location uri changed"
+ );
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_rtamo.js b/browser/components/newtab/test/browser/browser_aboutwelcome_rtamo.js
new file mode 100644
index 0000000000..4e8fe223fe
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_rtamo.js
@@ -0,0 +1,298 @@
+"use strict";
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { AddonRepository } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonRepository.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const TEST_ADDON_INFO = [
+ {
+ name: "Test Add-on",
+ sourceURI: { scheme: "https", spec: "https://test.xpi" },
+ icons: { 32: "test.png", 64: "test.png" },
+ type: "extension",
+ },
+];
+
+const TEST_ADDON_INFO_THEME = [
+ {
+ name: "Test Add-on",
+ sourceURI: { scheme: "https", spec: "https://test.xpi" },
+ icons: { 32: "test.png", 64: "test.png" },
+ screenshots: [{ url: "test.png" }],
+ type: "theme",
+ },
+];
+
+async function openRTAMOWelcomePage() {
+ // Can't properly stub the child/parent actors so instead
+ // we stub the modules they depend on for the RTAMO flow
+ // to ensure the right thing is rendered.
+ await ASRouter.forceAttribution({
+ source: "addons.mozilla.org",
+ medium: "referral",
+ campaign: "non-fx-button",
+ // with the sinon override, the id doesn't matter
+ content: "rta:whatever",
+ experiment: "ua-onboarding",
+ variation: "chrome",
+ ua: "Google Chrome 123",
+ dltoken: "00000000-0000-0000-0000-000000000000",
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ // Clear cache call is only possible in a testing environment
+ Services.env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
+ await ASRouter.forceAttribution({
+ source: "",
+ medium: "",
+ campaign: "",
+ content: "",
+ experiment: "",
+ variation: "",
+ ua: "",
+ dltoken: "",
+ });
+ });
+
+ return tab.linkedBrowser;
+}
+
+/**
+ * Setup and test RTAMO welcome UI
+ */
+async function test_screen_content(
+ browser,
+ experiment,
+ expectedSelectors = [],
+ unexpectedSelectors = []
+) {
+ await ContentTask.spawn(
+ browser,
+ { expectedSelectors, experiment, unexpectedSelectors },
+ async ({
+ expectedSelectors: expected,
+ experiment: experimentName,
+ unexpectedSelectors: unexpected,
+ }) => {
+ for (let selector of expected) {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(selector),
+ `Should render ${selector} in ${experimentName}`
+ );
+ }
+ for (let selector of unexpected) {
+ ok(
+ !content.document.querySelector(selector),
+ `Should not render ${selector} in ${experimentName}`
+ );
+ }
+ }
+ );
+}
+
+async function onButtonClick(browser, elementId) {
+ await ContentTask.spawn(
+ browser,
+ { elementId },
+ async ({ elementId: buttonId }) => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(buttonId),
+ buttonId
+ );
+ let button = content.document.querySelector(buttonId);
+ button.click();
+ }
+ );
+}
+
+/**
+ * Test the RTAMO welcome UI
+ */
+add_task(async function test_rtamo_aboutwelcome() {
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(AddonRepository, "getAddonsByIDs").resolves(TEST_ADDON_INFO);
+
+ let browser = await openRTAMOWelcomePage();
+
+ await test_screen_content(
+ browser,
+ "RTAMO UI",
+ // Expected selectors:
+ [
+ `div.onboardingContainer[style*='background: var(--mr-welcome-background-color) var(--mr-welcome-background-gradient)']`,
+ "h2[data-l10n-id='mr1-return-to-amo-addon-title']",
+ `h2[data-l10n-args='{"addon-name":"${TEST_ADDON_INFO[0].name}"}'`,
+ "div.rtamo-icon",
+ "button.primary[data-l10n-id='mr1-return-to-amo-add-extension-label']",
+ "button[data-l10n-id='onboarding-not-now-button-label']",
+ ],
+ // Unexpected selectors:
+ [
+ "main.AW_STEP1",
+ "main.AW_STEP2",
+ "main.AW_STEP3",
+ "div.tiles-container.info",
+ ]
+ );
+
+ await onButtonClick(
+ browser,
+ "button[data-l10n-id='onboarding-not-now-button-label']"
+ );
+ Assert.ok(gURLBar.focused, "Focus should be on awesome bar");
+
+ let windowGlobalParent = browser.browsingContext.currentWindowGlobal;
+ let aboutWelcomeActor = windowGlobalParent.getActor("AboutWelcome");
+ const messageSandbox = sinon.createSandbox();
+ // Stub AboutWelcomeParent Content Message Handler
+ messageSandbox.stub(aboutWelcomeActor, "onContentMessage");
+ registerCleanupFunction(() => {
+ messageSandbox.restore();
+ });
+
+ await onButtonClick(browser, "button.primary");
+ const { callCount } = aboutWelcomeActor.onContentMessage;
+ ok(
+ callCount === 2,
+ `${callCount} Stub called twice to install extension and send telemetry`
+ );
+
+ const installExtensionCall = aboutWelcomeActor.onContentMessage.getCall(0);
+ Assert.equal(
+ installExtensionCall.args[0],
+ "AWPage:SPECIAL_ACTION",
+ "send special action to install add on"
+ );
+ Assert.equal(
+ installExtensionCall.args[1].type,
+ "INSTALL_ADDON_FROM_URL",
+ "Special action type is INSTALL_ADDON_FROM_URL"
+ );
+ Assert.equal(
+ installExtensionCall.args[1].data.url,
+ "https://test.xpi",
+ "Install add on url"
+ );
+ Assert.equal(
+ installExtensionCall.args[1].data.telemetrySource,
+ "rtamo",
+ "Install add on telemetry source"
+ );
+ const telemetryCall = aboutWelcomeActor.onContentMessage.getCall(1);
+ Assert.equal(
+ telemetryCall.args[0],
+ "AWPage:TELEMETRY_EVENT",
+ "send add extension telemetry"
+ );
+ Assert.equal(
+ telemetryCall.args[1].event,
+ "CLICK_BUTTON",
+ "Telemetry event sent as INSTALL"
+ );
+ Assert.equal(
+ telemetryCall.args[1].event_context.source,
+ "ADD_EXTENSION_BUTTON",
+ "Source of the event is Add Extension Button"
+ );
+ Assert.equal(
+ telemetryCall.args[1].message_id,
+ "RTAMO_DEFAULT_WELCOME_EXTENSION",
+ "Message Id sent in telemetry for default RTAMO"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function test_rtamo_over_experiments() {
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(AddonRepository, "getAddonsByIDs").resolves(TEST_ADDON_INFO);
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: { screens: [], enabled: true },
+ });
+
+ let browser = await openRTAMOWelcomePage();
+
+ // If addon attribution exist, we should see RTAMO even if enrolled
+ // in about:welcome experiment
+ await test_screen_content(
+ browser,
+ "Experiment RTAMO UI",
+ // Expected selectors:
+ ["h2[data-l10n-id='mr1-return-to-amo-addon-title']"],
+ // Unexpected selectors:
+ []
+ );
+
+ await doExperimentCleanup();
+
+ browser = await openRTAMOWelcomePage();
+
+ await test_screen_content(
+ browser,
+ "No Experiment RTAMO UI",
+ // Expected selectors:
+ [
+ "div.onboardingContainer",
+ "h2[data-l10n-id='mr1-return-to-amo-addon-title']",
+ "div.rtamo-icon",
+ "button.primary",
+ "button.secondary",
+ ],
+ // Unexpected selectors:
+ [
+ "main.AW_STEP1",
+ "main.AW_STEP2",
+ "main.AW_STEP3",
+ "div.tiles-container.info",
+ ]
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function test_rtamo_primary_button_theme() {
+ let themeSandbox = sinon.createSandbox();
+ themeSandbox
+ .stub(AddonRepository, "getAddonsByIDs")
+ .resolves(TEST_ADDON_INFO_THEME);
+
+ let browser = await openRTAMOWelcomePage();
+
+ await test_screen_content(
+ browser,
+ "RTAMO UI",
+ // Expected selectors:
+ [
+ "div.onboardingContainer",
+ "h2[data-l10n-id='mr1-return-to-amo-addon-title']",
+ "div.rtamo-icon",
+ "button.primary[data-l10n-id='return-to-amo-add-theme-label']",
+ "button[data-l10n-id='onboarding-not-now-button-label']",
+ "img.rtamo-theme-icon",
+ ],
+ // Unexpected selectors:
+ [
+ "main.AW_STEP1",
+ "main.AW_STEP2",
+ "main.AW_STEP3",
+ "div.tiles-container.info",
+ ]
+ );
+
+ themeSandbox.restore();
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_screen_targeting.js b/browser/components/newtab/test/browser/browser_aboutwelcome_screen_targeting.js
new file mode 100644
index 0000000000..f321d6a659
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_screen_targeting.js
@@ -0,0 +1,152 @@
+"use strict";
+
+const { ShellService } = ChromeUtils.importESModule(
+ "resource:///modules/ShellService.sys.mjs"
+);
+
+const { TelemetryEnvironment } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryEnvironment.sys.mjs"
+);
+
+const TEST_DEFAULT_CONTENT = [
+ {
+ id: "AW_STEP1",
+ content: {
+ title: "Step 1",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "Secondary",
+ },
+ },
+ },
+ {
+ id: "AW_STEP2",
+ targeting: "false",
+ content: {
+ title: "Step 2",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "Secondary",
+ },
+ },
+ },
+ {
+ id: "AW_STEP3",
+ content: {
+ title: "Step 3",
+ primary_button: {
+ label: "Next",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: "Secondary",
+ },
+ },
+ },
+];
+
+const sandbox = sinon.createSandbox();
+
+add_setup(function initSandbox() {
+ requestLongerTimeout(2);
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
+
+const TEST_DEFAULT_JSON = JSON.stringify(TEST_DEFAULT_CONTENT);
+async function openAboutWelcome() {
+ await setAboutWelcomePref(true);
+ await setAboutWelcomeMultiStage(TEST_DEFAULT_JSON);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(tab);
+ });
+ return tab.linkedBrowser;
+}
+
+add_task(async function second_screen_filtered_by_targeting() {
+ let browser = await openAboutWelcome();
+ let aboutWelcomeActor = await getAboutWelcomeParent(browser);
+ // Stub AboutWelcomeParent Content Message Handler
+ sandbox.spy(aboutWelcomeActor, "onContentMessage");
+
+ await test_screen_content(
+ browser,
+ "multistage step 1",
+ // Expected selectors:
+ ["main.AW_STEP1"],
+ // Unexpected selectors:
+ ["main.AW_STEP2", "main.AW_STEP3"]
+ );
+
+ await onButtonClick(browser, "button.primary");
+
+ await test_screen_content(
+ browser,
+ "multistage step 3",
+ // Expected selectors:
+ ["main.AW_STEP3"],
+ // Unexpected selectors:
+ ["main.AW_STEP2", "main.AW_STEP1"]
+ );
+
+ sandbox.restore();
+ await popPrefs();
+});
+
+/**
+ * Test MR template easy setup content - Browser is pinned and
+ * not set as default and Windows 10 version 1703
+ */
+add_task(async function test_aboutwelcome_mr_template_easy_setup() {
+ if (!AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ return;
+ }
+
+ if (
+ //Windows version 1703
+ TelemetryEnvironment.currentEnvironment.system.os.windowsBuildNumber < 15063
+ ) {
+ return;
+ }
+
+ sandbox.stub(ShellService, "doesAppNeedPin").returns(false);
+ sandbox.stub(ShellService, "isDefaultBrowser").returns(false);
+
+ await clearHistoryAndBookmarks();
+
+ const { browser, cleanup } = await openMRAboutWelcome();
+
+ //should render easy setup
+ await test_screen_content(
+ browser,
+ "doesn't render pin, import and set to default",
+ //Expected selectors:
+ ["main.AW_EASY_SETUP"],
+ //Unexpected selectors:
+ ["main.AW_PIN_FIREFOX", "main.AW_SET_DEFAULT", "main.AW_IMPORT_SETTINGS"]
+ );
+
+ await cleanup();
+ await popPrefs();
+ sandbox.restore();
+});
diff --git a/browser/components/newtab/test/browser/browser_aboutwelcome_upgrade_multistage_mr.js b/browser/components/newtab/test/browser/browser_aboutwelcome_upgrade_multistage_mr.js
new file mode 100644
index 0000000000..a7c94b012b
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_aboutwelcome_upgrade_multistage_mr.js
@@ -0,0 +1,316 @@
+"use strict";
+
+const { OnboardingMessageProvider } = ChromeUtils.import(
+ "resource://activity-stream/lib/OnboardingMessageProvider.jsm"
+);
+const { SpecialMessageActions } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs"
+);
+const { assertFirefoxViewTabSelected, closeFirefoxViewTab } =
+ ChromeUtils.importESModule(
+ "resource://testing-common/FirefoxViewTestUtils.sys.mjs"
+ );
+
+const HOMEPAGE_PREF = "browser.startup.homepage";
+const NEWTAB_PREF = "browser.newtabpage.enabled";
+const PINPBM_DISABLED_PREF = "browser.startup.upgradeDialog.pinPBM.disabled";
+
+// A bunch of the helper functions here are variants of the helper functions in
+// browser_aboutwelcome_multistage_mr.js, because the onboarding
+// experience runs in the parent process rather than elsewhere.
+// If these start to get used in more than just the two files, it may become
+// worth refactoring them to avoid duplicated code, and hoisting them
+// into head.js.
+
+let sandbox;
+
+add_setup(async () => {
+ requestLongerTimeout(2);
+
+ await setAboutWelcomePref(true);
+
+ sandbox = sinon.createSandbox();
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false);
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(false);
+
+ sandbox.stub(SpecialMessageActions, "pinFirefoxToTaskbar").resolves();
+
+ registerCleanupFunction(async () => {
+ await popPrefs();
+ sandbox.restore();
+ });
+});
+
+/**
+ * Get the content by OnboardingMessageProvider.getUpgradeMessage(),
+ * discard any screens whose ids are not in the "screensToTest" array,
+ * and then open an upgrade dialog with just those screens.
+ *
+ * @param {Array} screensToTest
+ * A list of which screen ids to be displayed
+ *
+ * @returns Promise<Window>
+ * Resolves to the window global object for the dialog once it has been
+ * opened
+ */
+async function openMRUpgradeWelcome(screensToTest) {
+ const data = await OnboardingMessageProvider.getUpgradeMessage();
+
+ if (screensToTest) {
+ data.content.screens = data.content.screens.filter(screen =>
+ screensToTest.includes(screen.id)
+ );
+ }
+
+ sandbox.stub(OnboardingMessageProvider, "getUpgradeMessage").resolves(data);
+
+ let dialogOpenPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ null,
+ "chrome://browser/content/spotlight.html",
+ { isSubDialog: true }
+ );
+
+ Cc["@mozilla.org/browser/browserglue;1"]
+ .getService()
+ .wrappedJSObject._showUpgradeDialog();
+
+ let browser = await dialogOpenPromise;
+
+ OnboardingMessageProvider.getUpgradeMessage.restore();
+ return Promise.resolve(browser);
+}
+
+async function clickVisibleButton(browser, selector) {
+ await BrowserTestUtils.waitForCondition(
+ () => browser.document.querySelector(selector),
+ `waiting for selector ${selector}`,
+ 200, // interval
+ 100 // maxTries
+ );
+ browser.document.querySelector(selector).click();
+}
+
+async function test_upgrade_screen_content(
+ browser,
+ expected = [],
+ unexpected = []
+) {
+ for (let selector of expected) {
+ await TestUtils.waitForCondition(
+ () => browser.document.querySelector(selector),
+ `Should render ${selector}`
+ );
+ }
+ for (let selector of unexpected) {
+ Assert.ok(
+ !browser.document.querySelector(selector),
+ `Should not render ${selector}`
+ );
+ }
+}
+
+async function waitForDialogClose(browser) {
+ await BrowserTestUtils.waitForCondition(
+ () => !browser.top?.document.querySelector(".dialogFrame"),
+ "waiting for dialog to close"
+ );
+}
+
+/**
+ * Test homepage/newtab prefs start off as defaults and do not change
+ */
+add_task(async function test_aboutwelcome_upgrade_mr_prefs_off() {
+ let browser = await openMRUpgradeWelcome(["UPGRADE_GET_STARTED"]);
+
+ await test_upgrade_screen_content(
+ browser,
+ //Expected selectors:
+ ["main.UPGRADE_GET_STARTED"],
+ //Unexpected selectors:
+ ["main.PIN_FIREFOX"]
+ );
+
+ await clickVisibleButton(browser, ".action-buttons button.secondary");
+ await clickVisibleButton(browser, ".action-buttons button.primary");
+ await waitForDialogClose(browser);
+
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(HOMEPAGE_PREF),
+ "homepage pref should be default"
+ );
+ Assert.ok(
+ !Services.prefs.prefHasUserValue(NEWTAB_PREF),
+ "newtab pref should be default"
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+/*
+ *Test checkbox if needPrivatePin is true
+ */
+add_task(async function test_aboutwelcome_upgrade_mr_private_pin() {
+ OnboardingMessageProvider._doesAppNeedPin.resolves(true);
+ let browser = await openMRUpgradeWelcome(["UPGRADE_PIN_FIREFOX"]);
+
+ await test_upgrade_screen_content(
+ browser,
+ //Expected selectors:
+ ["main.UPGRADE_PIN_FIREFOX", "input#action-checkbox"],
+ //Unexpected selectors:
+ ["main.UPGRADE_COLORWAY"]
+ );
+ await clickVisibleButton(browser, ".action-buttons button.primary");
+ await waitForDialogClose(browser);
+
+ const pinStub = SpecialMessageActions.pinFirefoxToTaskbar;
+ Assert.equal(
+ pinStub.callCount,
+ 2,
+ "pinFirefoxToTaskbar should have been called twice"
+ );
+ Assert.ok(
+ // eslint-disable-next-line eqeqeq
+ pinStub.firstCall.lastArg != pinStub.secondCall.lastArg,
+ "pinFirefoxToTaskbar should have been called once for private, once not"
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+/*
+ *Test checkbox shouldn't be shown in get started screen
+ */
+
+add_task(async function test_aboutwelcome_upgrade_mr_private_pin_get_started() {
+ OnboardingMessageProvider._doesAppNeedPin.resolves(false);
+
+ let browser = await openMRUpgradeWelcome(["UPGRADE_GET_STARTED"]);
+
+ await test_upgrade_screen_content(
+ browser,
+ //Expected selectors
+ ["main.UPGRADE_GET_STARTED"],
+ //Unexpected selectors:
+ ["input#action-checkbox"]
+ );
+
+ await clickVisibleButton(browser, ".action-buttons button.secondary");
+
+ await waitForDialogClose(browser);
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+/*
+ *Test checkbox shouldn't be shown if needPrivatePin is false
+ */
+add_task(async function test_aboutwelcome_upgrade_mr_private_pin_not_needed() {
+ OnboardingMessageProvider._doesAppNeedPin
+ .resolves(true)
+ .withArgs(true)
+ .resolves(false);
+
+ let browser = await openMRUpgradeWelcome(["UPGRADE_PIN_FIREFOX"]);
+
+ await test_upgrade_screen_content(
+ browser,
+ //Expected selectors
+ ["main.UPGRADE_PIN_FIREFOX"],
+ //Unexpected selectors:
+ ["input#action-checkbox"]
+ );
+
+ await clickVisibleButton(browser, ".action-buttons button.secondary");
+ await waitForDialogClose(browser);
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+/*
+ * Make sure we don't get an extraneous checkbox here.
+ */
+add_task(
+ async function test_aboutwelcome_upgrade_mr_pin_not_needed_default_needed() {
+ OnboardingMessageProvider._doesAppNeedPin.resolves(false);
+ OnboardingMessageProvider._doesAppNeedDefault.resolves(false);
+
+ let browser = await openMRUpgradeWelcome(["UPGRADE_GET_STARTED"]);
+
+ await test_upgrade_screen_content(
+ browser,
+ //Expected selectors
+ ["main.UPGRADE_GET_STARTED"],
+ //Unexpected selectors:
+ ["input#action-checkbox"]
+ );
+
+ await clickVisibleButton(browser, ".action-buttons button.secondary");
+ await waitForDialogClose(browser);
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+);
+
+add_task(async function test_aboutwelcome_privacy_segmentation_pref() {
+ async function testPrivacySegmentation(enabled = false) {
+ await pushPrefs(["browser.privacySegmentation.preferences.show", enabled]);
+ let screenIds = ["UPGRADE_DATA_RECOMMENDATION", "UPGRADE_GRATITUDE"];
+ let browser = await openMRUpgradeWelcome(screenIds);
+ await test_upgrade_screen_content(
+ browser,
+ //Expected selectors
+ [`main.${screenIds[enabled ? 0 : 1]}`],
+ //Unexpected selectors:
+ [`main.${screenIds[enabled ? 1 : 0]}`]
+ );
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await popPrefs();
+ }
+
+ for (let enabled of [true, false]) {
+ await testPrivacySegmentation(enabled);
+ }
+});
+
+add_task(async function test_aboutwelcome_upgrade_show_firefox_view() {
+ let browser = await openMRUpgradeWelcome(["UPGRADE_GRATITUDE"]);
+
+ // execution
+ await test_upgrade_screen_content(
+ browser,
+ //Expected selectors
+ ["main.UPGRADE_GRATITUDE"],
+ //Unexpected selectors:
+ []
+ );
+ await clickVisibleButton(browser, ".action-buttons button.primary");
+
+ // verification
+ await BrowserTestUtils.waitForEvent(gBrowser, "TabSwitchDone");
+ assertFirefoxViewTabSelected(gBrowser.ownerGlobal);
+
+ closeFirefoxViewTab(gBrowser.ownerGlobal);
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+/*
+ *Checkbox shouldn't be shown if pinPBMDisabled pref is true
+ */
+add_task(async function test_aboutwelcome_upgrade_mr_private_pin_not_needed() {
+ OnboardingMessageProvider._doesAppNeedPin.resolves(true);
+ await pushPrefs([PINPBM_DISABLED_PREF, true]);
+
+ const browser = await openMRUpgradeWelcome(["UPGRADE_PIN_FIREFOX"]);
+
+ await test_upgrade_screen_content(
+ browser,
+ //Expected selectors
+ ["main.UPGRADE_PIN_FIREFOX"],
+ //Unexpected selectors:
+ ["input#action-checkbox"]
+ );
+
+ await clickVisibleButton(browser, ".action-buttons button.secondary");
+ await waitForDialogClose(browser);
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/components/newtab/test/browser/browser_as_load_location.js b/browser/components/newtab/test/browser/browser_as_load_location.js
new file mode 100644
index 0000000000..f11b6cf503
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_as_load_location.js
@@ -0,0 +1,44 @@
+"use strict";
+
+/**
+ * Helper to test that a newtab page loads its html document.
+ *
+ * @param selector {String} CSS selector to find an element in newtab content
+ * @param message {String} Description of the test printed with the assertion
+ */
+async function checkNewtabLoads(selector, message) {
+ // simulate a newtab open as a user would
+ BrowserOpenTab();
+
+ // wait until the browser loads
+ let browser = gBrowser.selectedBrowser;
+ await waitForPreloaded(browser);
+
+ // check what the content task thinks has been loaded.
+ let found = await ContentTask.spawn(
+ browser,
+ selector,
+ arg => content.document.querySelector(arg) !== null
+ );
+ ok(found, message);
+
+ // avoid leakage
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
+// Test with activity stream on
+async function checkActivityStreamLoads() {
+ await checkNewtabLoads(
+ "body.activity-stream",
+ "Got <body class='activity-stream'> Element"
+ );
+}
+
+// Run a first time not from a preloaded browser
+add_task(async function checkActivityStreamNotPreloadedLoad() {
+ NewTabPagePreloading.removePreloadedBrowser(window);
+ await checkActivityStreamLoads();
+});
+
+// Run a second time from a preloaded browser
+add_task(checkActivityStreamLoads);
diff --git a/browser/components/newtab/test/browser/browser_as_render.js b/browser/components/newtab/test/browser/browser_as_render.js
new file mode 100644
index 0000000000..2e82786b16
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_as_render.js
@@ -0,0 +1,83 @@
+"use strict";
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ]);
+ },
+ test: function test_render_search() {
+ let search = content.document.getElementById("newtab-search-text");
+ ok(search, "Got the search box");
+ isnot(
+ search.placeholder,
+ "search_web_placeholder",
+ "Search box is localized"
+ );
+ },
+});
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ true,
+ ]);
+ },
+ test: function test_render_search_handoff() {
+ let search = content.document.querySelector(".search-handoff-button");
+ ok(search, "Got the search handoff button");
+ },
+});
+
+test_newtab(function test_render_topsites() {
+ let topSites = content.document.querySelector(".top-sites-list");
+ ok(topSites, "Got the top sites section");
+});
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.feeds.topsites",
+ false,
+ ]);
+ },
+ test: function test_render_no_topsites() {
+ let topSites = content.document.querySelector(".top-sites-list");
+ ok(!topSites, "No top sites section");
+ },
+});
+
+// This next test runs immediately after test_render_no_topsites to make sure
+// the topsites pref is restored
+test_newtab(function test_render_topsites_again() {
+ let topSites = content.document.querySelector(".top-sites-list");
+ ok(topSites, "Got the top sites section again");
+});
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.logowordmark.alwaysVisible",
+ false,
+ ]);
+ },
+ test: function test_render_logo_false() {
+ let logoWordmark = content.document.querySelector(".logo-and-wordmark");
+ ok(!logoWordmark, "The logo is not rendered when pref is false");
+ },
+});
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.logowordmark.alwaysVisible",
+ true,
+ ]);
+ },
+ test: function test_render_logo() {
+ let logoWordmark = content.document.querySelector(".logo-and-wordmark");
+ ok(logoWordmark, "The logo is rendered when pref is true");
+ },
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_bug1761522.js b/browser/components/newtab/test/browser/browser_asrouter_bug1761522.js
new file mode 100644
index 0000000000..13f5ac9b9c
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_bug1761522.js
@@ -0,0 +1,232 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ASRouter, MessageLoaderUtils } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/PanelTestProvider.sys.mjs"
+);
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+const { RemoteL10n } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/RemoteL10n.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+// This pref is used to override the Remote Settings server URL in tests.
+// See SERVER_URL in services/settings/Utils.jsm for more details.
+const RS_SERVER_PREF = "services.settings.server";
+
+const FLUENT_CONTENT = "asrouter-test-string = Test Test Test\n";
+
+async function serveRemoteSettings() {
+ const server = new HttpServer();
+ server.start(-1);
+
+ const baseURL = `http://localhost:${server.identity.primaryPort}/`;
+ const attachmentUuid = crypto.randomUUID();
+ const attachment = new TextEncoder().encode(FLUENT_CONTENT);
+
+ // Serve an index so RS knows where to fetch images from.
+ server.registerPathHandler("/v1/", (request, response) => {
+ response.write(
+ JSON.stringify({
+ capabilities: {
+ attachments: {
+ base_url: `${baseURL}cdn`,
+ },
+ },
+ })
+ );
+ });
+
+ // Serve the ms-language-packs record for cfr-v1-ja-JP-mac, pointing to an attachment.
+ server.registerPathHandler(
+ "/v1/buckets/main/collections/ms-language-packs/records/cfr-v1-ja-JP-mac",
+ (request, response) => {
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader(
+ "Content-type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(
+ JSON.stringify({
+ permissions: {},
+ data: {
+ attachment: {
+ hash: "f9aead2693c4ff95c2764df72b43fdf5b3490ed06414588843848f991136040b",
+ size: attachment.buffer.byteLength,
+ filename: "asrouter.ftl",
+ location: `main-workspace/ms-language-packs/${attachmentUuid}`,
+ },
+ id: "cfr-v1-ja-JP-mac",
+ last_modified: Date.now(),
+ },
+ })
+ );
+ }
+ );
+
+ // Serve the attachment for ms-language-packs/cfr-va-ja-JP-mac.
+ server.registerPathHandler(
+ `/cdn/main-workspace/ms-language-packs/${attachmentUuid}`,
+ (request, response) => {
+ const stream = Cc[
+ "@mozilla.org/io/arraybuffer-input-stream;1"
+ ].createInstance(Ci.nsIArrayBufferInputStream);
+ stream.setData(attachment.buffer, 0, attachment.buffer.byteLength);
+
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader("Content-type", "application/octet-stream");
+ response.bodyOutputStream.writeFrom(stream, attachment.buffer.byteLength);
+ }
+ );
+
+ // Serve the list of changed collections. cfr must have changed, otherwise we
+ // won't attempt to fetch the cfr records (and then won't fetch
+ // ms-language-packs).
+ server.registerPathHandler(
+ "/v1/buckets/monitor/collections/changes/changeset",
+ (request, response) => {
+ const now = Date.now();
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader(
+ "Content-type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(
+ JSON.stringify({
+ timestamp: now,
+ changes: [
+ {
+ host: `localhost:${server.identity.primaryPort}`,
+ last_modified: now,
+ bucket: "main",
+ collection: "cfr",
+ },
+ ],
+ metadata: {},
+ })
+ );
+ }
+ );
+
+ const message = await PanelTestProvider.getMessages().then(msgs =>
+ msgs.find(msg => msg.id === "PERSONALIZED_CFR_MESSAGE")
+ );
+
+ // Serve the "changed" cfr entries. If there are no changes, then ASRouter
+ // won't re-fetch ms-language-packs.
+ server.registerPathHandler(
+ "/v1/buckets/main/collections/cfr/changeset",
+ (request, response) => {
+ const now = Date.now();
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader(
+ "Content-type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(
+ JSON.stringify({
+ timestamp: now,
+ changes: [message],
+ metadata: {},
+ })
+ );
+ }
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[RS_SERVER_PREF, `${baseURL}v1`]],
+ });
+
+ return async () => {
+ await new Promise(resolve => server.stop(() => resolve()));
+ await SpecialPowers.popPrefEnv();
+ };
+}
+
+add_task(async function test_asrouter() {
+ const MS_LANGUAGE_PACKS_DIR = PathUtils.join(
+ PathUtils.localProfileDir,
+ "settings",
+ "main",
+ "ms-language-packs"
+ );
+ const sandbox = sinon.createSandbox();
+ const stop = await serveRemoteSettings();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ JSON.stringify({
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ updateCyleInMs: 3600000,
+ }),
+ ],
+ ],
+ });
+ const localeService = Services.locale;
+ RemoteSettings("cfr").verifySignature = false;
+
+ registerCleanupFunction(async () => {
+ RemoteSettings("cfr").verifySignature = true;
+ Services.locale = localeService;
+ await SpecialPowers.popPrefEnv();
+ await stop();
+ sandbox.restore();
+ await IOUtils.remove(MS_LANGUAGE_PACKS_DIR, { recursive: true });
+ RemoteL10n.reloadL10n();
+ });
+
+ // We can't stub Services.locale.appLocaleAsBCP47 directly because its an
+ // XPCOM_Native object.
+ const fakeLocaleService = new Proxy(localeService, {
+ get(obj, prop) {
+ if (prop === "appLocaleAsBCP47") {
+ return "ja-JP-macos";
+ }
+ return obj[prop];
+ },
+ });
+
+ const localeSpy = sandbox.spy(MessageLoaderUtils, "locale", ["get"]);
+ Services.locale = fakeLocaleService;
+
+ const cfrProvider = ASRouter.state.providers.find(p => p.id === "cfr");
+ await ASRouter.loadMessagesFromAllProviders([cfrProvider]);
+
+ Assert.equal(
+ Services.locale.appLocaleAsBCP47,
+ "ja-JP-macos",
+ "Locale service returns ja-JP-macos"
+ );
+ Assert.ok(localeSpy.get.called, "MessageLoaderUtils.locale getter called");
+ Assert.ok(
+ localeSpy.get.alwaysReturned("ja-JP-mac"),
+ "MessageLoaderUtils.locale getter returned expected locale ja-JP-mac"
+ );
+
+ const path = PathUtils.join(
+ MS_LANGUAGE_PACKS_DIR,
+ "browser",
+ "newtab",
+ "asrouter.ftl"
+ );
+ Assert.ok(await IOUtils.exists(path), "asrouter.ftl was downloaded");
+ Assert.equal(
+ await IOUtils.readUTF8(path),
+ FLUENT_CONTENT,
+ "asrouter.ftl content matches expected"
+ );
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_bug1800087.js b/browser/components/newtab/test/browser/browser_asrouter_bug1800087.js
new file mode 100644
index 0000000000..dd7138d00d
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_bug1800087.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/. */
+
+"use strict";
+
+// TODO (Bug 1800937): Remove this whole test along with the migration code
+// after the next watershed release.
+
+const { ASRouterNewTabHook } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/ASRouterNewTabHook.sys.mjs"
+);
+const { ASRouterDefaultConfig } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouterDefaultConfig.jsm"
+);
+
+add_setup(() => ASRouterNewTabHook.destroy());
+
+// Test that the old pref format is migrated correctly to the new format.
+// provider.bucket -> provider.collection
+add_task(async function test_newtab_asrouter() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ JSON.stringify({
+ id: "cfr",
+ enabled: true,
+ type: "local",
+ bucket: "cfr", // The pre-migration property name is bucket.
+ updateCyleInMs: 3600000,
+ }),
+ ],
+ ],
+ });
+
+ await ASRouterNewTabHook.createInstance(ASRouterDefaultConfig());
+ const hook = await ASRouterNewTabHook.getInstance();
+ const router = hook._router;
+ if (!router.initialized) {
+ await router.waitForInitialized;
+ }
+
+ // Test that the pref's bucket is migrated to collection.
+ let cfrProvider = router.state.providers.find(p => p.id === "cfr");
+ Assert.equal(cfrProvider.collection, "cfr", "The collection name is correct");
+ Assert.ok(!cfrProvider.bucket, "The bucket name is removed");
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_cfr.js b/browser/components/newtab/test/browser/browser_asrouter_cfr.js
new file mode 100644
index 0000000000..3c163e2a14
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_cfr.js
@@ -0,0 +1,914 @@
+const { CFRPageActions } = ChromeUtils.import(
+ "resource://activity-stream/lib/CFRPageActions.jsm"
+);
+const { ASRouterTriggerListeners } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouterTriggerListeners.jsm"
+);
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/CFRMessageProvider.sys.mjs"
+);
+
+const { TelemetryFeed } = ChromeUtils.import(
+ "resource://activity-stream/lib/TelemetryFeed.jsm"
+);
+
+const createDummyRecommendation = ({
+ action,
+ category,
+ heading_text,
+ layout,
+ skip_address_bar_notifier,
+ show_in_private_browsing,
+ template,
+}) => {
+ let recommendation = {
+ template,
+ groups: ["mochitest-group"],
+ content: {
+ layout: layout || "addon_recommendation",
+ category,
+ anchor_id: "page-action-buttons",
+ skip_address_bar_notifier,
+ show_in_private_browsing,
+ heading_text: heading_text || "Mochitest",
+ info_icon: {
+ label: { attributes: { tooltiptext: "Why am I seeing this" } },
+ sumo_path: "extensionrecommendations",
+ },
+ icon: "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg",
+ icon_dark_theme:
+ "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg",
+ learn_more: "extensionrecommendations",
+ addon: {
+ id: "addon-id",
+ title: "Addon name",
+ icon: "chrome://browser/skin/addons/addon-install-downloading.svg",
+ author: "Author name",
+ amo_url: "https://example.com",
+ },
+ descriptionDetails: { steps: [] },
+ text: "Mochitest",
+ buttons: {
+ primary: {
+ label: {
+ value: "OK",
+ attributes: { accesskey: "O" },
+ },
+ action: {
+ type: action.type,
+ data: {},
+ },
+ },
+ secondary: [
+ {
+ label: {
+ value: "Cancel",
+ attributes: { accesskey: "C" },
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: {
+ value: "Cancel 1",
+ attributes: { accesskey: "A" },
+ },
+ },
+ {
+ label: {
+ value: "Cancel 2",
+ attributes: { accesskey: "B" },
+ },
+ },
+ ],
+ },
+ },
+ };
+ recommendation.content.notification_text = new String("Mochitest"); // eslint-disable-line
+ recommendation.content.notification_text.attributes = {
+ tooltiptext: "Mochitest tooltip",
+ "a11y-announcement": "Mochitest announcement",
+ };
+ return recommendation;
+};
+
+function checkCFRAddonsElements(notification) {
+ Assert.ok(notification.hidden === false, "Panel should be visible");
+ Assert.equal(
+ notification.getAttribute("data-notification-category"),
+ "addon_recommendation",
+ "Panel have correct data attribute"
+ );
+ Assert.ok(
+ notification.querySelector("#cfr-notification-footer-text-and-addon-info"),
+ "Panel should have addon info container"
+ );
+ Assert.ok(
+ notification.querySelector("#cfr-notification-footer-filled-stars"),
+ "Panel should have addon rating info"
+ );
+ Assert.ok(
+ notification.querySelector("#cfr-notification-author"),
+ "Panel should have author info"
+ );
+}
+
+function checkCFRTrackingProtectionMilestone(notification) {
+ Assert.ok(notification.hidden === false, "Panel should be visible");
+ Assert.ok(
+ notification.getAttribute("data-notification-category") === "short_message",
+ "Panel have correct data attribute"
+ );
+}
+
+function clearNotifications() {
+ for (let notification of PopupNotifications._currentNotifications) {
+ notification.remove();
+ }
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+}
+
+function trigger_cfr_panel(
+ browser,
+ trigger,
+ {
+ action = { type: "CANCEL" },
+ heading_text,
+ category = "cfrAddons",
+ layout,
+ skip_address_bar_notifier = false,
+ use_single_secondary_button = false,
+ show_in_private_browsing = false,
+ template = "cfr_doorhanger",
+ } = {}
+) {
+ // a fake action type will result in the action being ignored
+ const recommendation = createDummyRecommendation({
+ action,
+ category,
+ heading_text,
+ layout,
+ skip_address_bar_notifier,
+ show_in_private_browsing,
+ template,
+ });
+ if (category !== "cfrAddons") {
+ delete recommendation.content.addon;
+ }
+ if (use_single_secondary_button) {
+ recommendation.content.buttons.secondary = [
+ recommendation.content.buttons.secondary[0],
+ ];
+ }
+
+ clearNotifications();
+ return CFRPageActions.addRecommendation(
+ browser,
+ trigger,
+ recommendation,
+ // Use the real AS dispatch method to trigger real notifications
+ ASRouter.dispatchCFRAction
+ );
+}
+
+add_setup(async function () {
+ // Store it in order to restore to the original value
+ const { _fetchLatestAddonVersion } = CFRPageActions;
+ // Prevent fetching the real addon url and making a network request
+ CFRPageActions._fetchLatestAddonVersion = x => "http://example.com";
+
+ registerCleanupFunction(() => {
+ CFRPageActions._fetchLatestAddonVersion = _fetchLatestAddonVersion;
+ clearNotifications();
+ CFRPageActions.clearRecommendations();
+ });
+});
+
+add_task(async function test_cfr_notification_show() {
+ const sendPingStub = sinon.stub(
+ TelemetryFeed.prototype,
+ "sendStructuredIngestionEvent"
+ );
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ const response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ const oldFocus = document.activeElement;
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .hidden === false,
+ "Panel should be visible"
+ );
+ Assert.equal(
+ document.activeElement,
+ oldFocus,
+ "Focus didn't move when panel was shown"
+ );
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .button
+ );
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+
+ Assert.ok(sendPingStub.callCount >= 1, "Recorded some events");
+ let cfrPing = sendPingStub.args.find(args => args[2] === "cfr");
+ Assert.equal(cfrPing[0].source, "CFR", "Got a CFR event");
+ sendPingStub.restore();
+});
+
+add_task(async function test_cfr_notification_show() {
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ let response = await trigger_cfr_panel(browser, "example.com", {
+ heading_text: "First Message",
+ });
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+
+ // Try adding another message
+ response = await trigger_cfr_panel(browser, "example.com", {
+ heading_text: "Second Message",
+ });
+ Assert.equal(
+ response,
+ false,
+ "Should return false if second call did not add the message"
+ );
+
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .hidden === false,
+ "Panel should be visible"
+ );
+
+ Assert.equal(
+ document.getElementById("cfr-notification-header-label").value,
+ "First Message",
+ "The first message should be visible"
+ );
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .button
+ );
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+});
+
+add_task(async function test_cfr_notification_minimize() {
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ let response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => gURLBar.hasAttribute("cfr-recommendation-state"),
+ "Wait for the notification to show up and have a state"
+ );
+ Assert.ok(
+ gURLBar.getAttribute("cfr-recommendation-state") === "expanded",
+ "CFR recomendation state is correct"
+ );
+
+ gURLBar.focus();
+
+ await BrowserTestUtils.waitForCondition(
+ () => gURLBar.getAttribute("cfr-recommendation-state") === "collapsed",
+ "After urlbar focus the CFR notification should collapse"
+ );
+
+ // Open the panel and click to dismiss to ensure cleanup
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+});
+
+add_task(async function test_cfr_notification_minimize_2() {
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ let response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => gURLBar.hasAttribute("cfr-recommendation-state"),
+ "Wait for the notification to show up and have a state"
+ );
+ Assert.ok(
+ gURLBar.getAttribute("cfr-recommendation-state") === "expanded",
+ "CFR recomendation state is correct"
+ );
+
+ // Open the panel and click to dismiss to ensure cleanup
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .secondaryButton,
+ "There should be a cancel button"
+ );
+
+ // Click the Not Now button
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .secondaryButton.click();
+
+ await hidePanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification"),
+ "The notification should not dissapear"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => gURLBar.getAttribute("cfr-recommendation-state") === "collapsed",
+ "Clicking the secondary button should collapse the notification"
+ );
+
+ clearNotifications();
+ CFRPageActions.clearRecommendations();
+});
+
+add_task(async function test_cfr_addon_install() {
+ // addRecommendation checks that scheme starts with http and host matches
+ const browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ const response = await trigger_cfr_panel(browser, "example.com", {
+ action: { type: "INSTALL_ADDON_FROM_URL" },
+ });
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .hidden === false,
+ "Panel should be visible"
+ );
+ checkCFRAddonsElements(
+ document.getElementById("contextual-feature-recommendation-notification")
+ );
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .button
+ );
+ const hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ await BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+
+ let [notification] = PopupNotifications.panel.childNodes;
+ // Trying to install the addon will trigger a progress popup or an error popup if
+ // running the test multiple times in a row
+ Assert.ok(
+ notification.id === "addon-progress-notification" ||
+ notification.id === "addon-install-failed-notification",
+ "Should try to install the addon"
+ );
+
+ clearNotifications();
+});
+
+add_task(
+ async function test_cfr_tracking_protection_milestone_notification_remove() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.contentblocking.cfr-milestone.milestone-achieved", 1000],
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ `{"id":"cfr","enabled":true,"type":"local","localProvider":"CFRMessageProvider","updateCycleInMs":3600000}`,
+ ],
+ ],
+ });
+
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ event: "ContentBlockingMilestone",
+ },
+ },
+ "SiteProtection:ContentBlockingMilestone"
+ );
+
+ await showPanel;
+
+ const notification = document.getElementById(
+ "contextual-feature-recommendation-notification"
+ );
+
+ checkCFRTrackingProtectionMilestone(notification);
+
+ Assert.ok(notification.secondaryButton);
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ notification.secondaryButton.click();
+ await hidePanel;
+ await SpecialPowers.popPrefEnv();
+ clearNotifications();
+ }
+);
+
+add_task(async function test_cfr_addon_and_features_show() {
+ // addRecommendation checks that scheme starts with http and host matches
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ // Trigger Feature CFR
+ let response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ let showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ const notification = document.getElementById(
+ "contextual-feature-recommendation-notification"
+ );
+ checkCFRAddonsElements(notification);
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(notification.button);
+ let hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+
+ // Trigger Addon CFR
+ response = await trigger_cfr_panel(browser, "example.com", {
+ action: { type: "PIN_CURRENT_TAB" },
+ category: "cfrAddons",
+ });
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Open the panel
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ Assert.ok(
+ document.getElementById("contextual-feature-recommendation-notification")
+ .hidden === false,
+ "Panel should be visible"
+ );
+ checkCFRAddonsElements(
+ document.getElementById("contextual-feature-recommendation-notification")
+ );
+
+ // Check there is a primary button and click it. It will trigger the callback.
+ Assert.ok(notification.button);
+ hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+
+ // Clicking the primary action also removes the notification
+ Assert.equal(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "Should have removed the notification"
+ );
+});
+
+add_task(async function test_onLocationChange_cb() {
+ let count = 0;
+ const triggerHandler = () => ++count;
+ const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/blue_page.html";
+ const browser = gBrowser.selectedBrowser;
+
+ await ASRouterTriggerListeners.get("openURL").init(triggerHandler, [
+ "example.com",
+ ]);
+
+ BrowserTestUtils.loadURIString(browser, "about:blank");
+ await BrowserTestUtils.browserLoaded(browser, false, "about:blank");
+
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ Assert.equal(count, 1, "Count navigation to example.com");
+
+ // Anchor scroll triggers a location change event with the same document
+ // https://searchfox.org/mozilla-central/rev/8848b9741fc4ee4e9bc3ae83ea0fc048da39979f/uriloader/base/nsIWebProgressListener.idl#400-403
+ BrowserTestUtils.loadURIString(browser, "http://example.com/#foo");
+ await BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ "http://example.com/#foo"
+ );
+
+ Assert.equal(count, 1, "It should ignore same page navigation");
+
+ BrowserTestUtils.loadURIString(browser, TEST_URL);
+ await BrowserTestUtils.browserLoaded(browser, false, TEST_URL);
+
+ Assert.equal(count, 2, "We moved to a new document");
+
+ registerCleanupFunction(() => {
+ ASRouterTriggerListeners.get("openURL").uninit();
+ });
+});
+
+add_task(async function test_matchPattern() {
+ let count = 0;
+ const triggerHandler = () => ++count;
+ const frequentVisitsTrigger = ASRouterTriggerListeners.get("frequentVisits");
+ await frequentVisitsTrigger.init(triggerHandler, [], ["*://*.example.com/"]);
+
+ const browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("example.com").length === 1,
+ "Registered pattern matched the current location"
+ );
+
+ BrowserTestUtils.loadURIString(browser, "about:config");
+ await BrowserTestUtils.browserLoaded(browser, false, "about:config");
+
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("example.com").length === 1,
+ "Navigated to a new page but not a match"
+ );
+
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("example.com").length === 1,
+ "Navigated to a location that matches the pattern but within 15 mins"
+ );
+
+ BrowserTestUtils.loadURIString(browser, "http://www.example.com/");
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ "http://www.example.com/"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("www.example.com").length === 1,
+ "www.example.com is a different host that also matches the pattern."
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => frequentVisitsTrigger._visits.get("example.com").length === 1,
+ "www.example.com is a different host that also matches the pattern."
+ );
+
+ registerCleanupFunction(() => {
+ ASRouterTriggerListeners.get("frequentVisits").uninit();
+ });
+});
+
+add_task(async function test_providerNames() {
+ const providersBranch =
+ "browser.newtabpage.activity-stream.asrouter.providers.";
+ const cfrProviderPrefs = Services.prefs.getChildList(providersBranch);
+ for (const prefName of cfrProviderPrefs) {
+ const prefValue = JSON.parse(Services.prefs.getStringPref(prefName));
+ if (prefValue && prefValue.id) {
+ // Snippets are disabled in tests and value is set to []
+ Assert.equal(
+ prefValue.id,
+ prefName.slice(providersBranch.length),
+ "Provider id and pref name do not match"
+ );
+ }
+ }
+});
+
+add_task(async function test_cfr_notification_keyboard() {
+ // addRecommendation checks that scheme starts with http and host matches
+ const browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ const response = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ response,
+ "Should return true if addRecommendation checks were successful"
+ );
+
+ // Open the panel with the keyboard.
+ // Toolbar buttons aren't always focusable; toolbar keyboard navigation
+ // makes them focusable on demand. Therefore, we must force focus.
+ const button = document.getElementById("contextual-feature-recommendation");
+ button.setAttribute("tabindex", "-1");
+ button.focus();
+ button.removeAttribute("tabindex");
+
+ let focused = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "focus",
+ true
+ );
+ EventUtils.synthesizeKey(" ");
+ await focused;
+ Assert.ok(true, "Focus inside panel after button pressed");
+
+ let hidden = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape");
+ await hidden;
+ Assert.ok(true, "Panel hidden after Escape pressed");
+
+ const showPanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Need to dismiss the notification to clear the RecommendationMap
+ document.getElementById("contextual-feature-recommendation").click();
+ await showPanel;
+
+ const hidePanel = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ document
+ .getElementById("contextual-feature-recommendation-notification")
+ .button.click();
+ await hidePanel;
+});
+
+add_task(function test_updateCycleForProviders() {
+ Services.prefs
+ .getChildList("browser.newtabpage.activity-stream.asrouter.providers.")
+ .forEach(provider => {
+ const prefValue = JSON.parse(Services.prefs.getStringPref(provider, ""));
+ if (prefValue && prefValue.type === "remote-settings") {
+ Assert.ok(prefValue.updateCycleInMs);
+ }
+ });
+});
+
+add_task(async function test_heartbeat_tactic_2() {
+ clearNotifications();
+ registerCleanupFunction(() => {
+ // Remove the tab opened by clicking the heartbeat message
+ gBrowser.removeCurrentTab();
+ clearNotifications();
+ });
+
+ const msg = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "HEARTBEAT_TACTIC_2"
+ );
+ const shown = await CFRPageActions.addRecommendation(
+ gBrowser.selectedBrowser,
+ null,
+ {
+ ...msg,
+ id: `HEARTBEAT_MOCHITEST_${Date.now()}`,
+ groups: ["mochitest-group"],
+ targeting: true,
+ },
+ // Use the real AS dispatch method to trigger real notifications
+ ASRouter.dispatchCFRAction
+ );
+
+ Assert.ok(shown, "Heartbeat CFR added");
+
+ // Wait for visibility change
+ BrowserTestUtils.waitForCondition(
+ () => document.getElementById("contextual-feature-recommendation"),
+ "Heartbeat button exists"
+ );
+
+ let newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ Services.urlFormatter.formatURL(msg.content.action.url),
+ true
+ );
+
+ document.getElementById("contextual-feature-recommendation").click();
+
+ await newTabPromise;
+});
+
+add_task(async function test_cfr_doorhanger_in_private_window() {
+ const win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+ const sendPingStub = sinon.stub(
+ TelemetryFeed.prototype,
+ "sendStructuredIngestionEvent"
+ );
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "http://example.com/"
+ );
+ const browser = tab.linkedBrowser;
+
+ const response1 = await trigger_cfr_panel(browser, "example.com");
+ Assert.ok(
+ !response1,
+ "CFR should not be shown in a private window if show_in_private_browsing is false"
+ );
+
+ const response2 = await trigger_cfr_panel(browser, "example.com", {
+ show_in_private_browsing: true,
+ });
+ Assert.ok(
+ response2,
+ "CFR should be shown in a private window if show_in_private_browsing is true"
+ );
+
+ const shownPromise = BrowserTestUtils.waitForEvent(
+ win.PopupNotifications.panel,
+ "popupshown"
+ );
+ win.document.getElementById("contextual-feature-recommendation").click();
+ await shownPromise;
+
+ const hiddenPromise = BrowserTestUtils.waitForEvent(
+ win.PopupNotifications.panel,
+ "popuphidden"
+ );
+ const button = win.document.getElementById(
+ "contextual-feature-recommendation-notification"
+ )?.button;
+ Assert.ok(button, "CFR doorhanger button found");
+ button.click();
+ await hiddenPromise;
+
+ Assert.greater(sendPingStub.callCount, 0, "Recorded CFR telemetry");
+ const cfrPing = sendPingStub.args.find(args => args[2] === "cfr");
+ Assert.equal(cfrPing[0].source, "CFR", "Got a CFR event");
+ Assert.equal(
+ cfrPing[0].message_id,
+ "n/a",
+ "Omitted message_id consistent with CFR telemetry policy"
+ );
+ Assert.equal(
+ cfrPing[0].client_id,
+ undefined,
+ "Omitted client_id consistent with CFR telemetry policy"
+ );
+
+ sendPingStub.restore();
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js b/browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js
new file mode 100644
index 0000000000..719c0d3512
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_experimentsAPILoader.js
@@ -0,0 +1,505 @@
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { RemoteSettingsExperimentLoader } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
+);
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+const { ExperimentFakes, ExperimentTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+const { ExperimentManager } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/ExperimentManager.sys.mjs"
+);
+const { TelemetryFeed } = ChromeUtils.import(
+ "resource://activity-stream/lib/TelemetryFeed.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const MESSAGE_CONTENT = {
+ id: "xman_test_message",
+ groups: [],
+ content: {
+ text: "This is a test CFR",
+ addon: {
+ id: "954390",
+ icon: "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png",
+ title: "Facebook Container",
+ users: "1455872",
+ author: "Mozilla",
+ rating: "4.5",
+ amo_url: "https://addons.mozilla.org/firefox/addon/facebook-container/",
+ },
+ buttons: {
+ primary: {
+ label: {
+ string_id: "cfr-doorhanger-extension-ok-button",
+ },
+ action: {
+ data: {
+ url: "about:blank",
+ },
+ type: "INSTALL_ADDON_FROM_URL",
+ },
+ },
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-cancel-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ data: {
+ origin: "CFR",
+ category: "general-cfraddons",
+ },
+ type: "OPEN_PREFERENCES_PAGE",
+ },
+ },
+ ],
+ },
+ category: "cfrAddons",
+ layout: "short_message",
+ bucket_id: "CFR_M1",
+ info_icon: {
+ label: {
+ string_id: "cfr-doorhanger-extension-sumo-link",
+ },
+ sumo_path: "extensionrecommendations",
+ },
+ heading_text: "Welcome to the experiment",
+ notification_text: {
+ string_id: "cfr-doorhanger-extension-notification2",
+ },
+ },
+ trigger: {
+ id: "openURL",
+ params: [
+ "www.facebook.com",
+ "facebook.com",
+ "www.instagram.com",
+ "instagram.com",
+ "www.whatsapp.com",
+ "whatsapp.com",
+ "web.whatsapp.com",
+ "www.messenger.com",
+ "messenger.com",
+ ],
+ },
+ template: "cfr_doorhanger",
+ frequency: {
+ lifetime: 3,
+ },
+ targeting: "true",
+};
+
+const getExperiment = async feature => {
+ let recipe = ExperimentFakes.recipe(
+ // In tests by default studies/experiments are turned off. We turn them on
+ // to run the test and rollback at the end. Cleanup causes unenrollment so
+ // for cases where the test runs multiple times we need unique ids.
+ `test_xman_${feature}_${Date.now()}`,
+ {
+ id: "xman_test_message",
+ bucketConfig: {
+ count: 100,
+ start: 0,
+ total: 100,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ }
+ );
+ recipe.branches[0].features[0].featureId = feature;
+ recipe.branches[0].features[0].value = MESSAGE_CONTENT;
+ recipe.branches[1].features[0].featureId = feature;
+ recipe.branches[1].features[0].value = MESSAGE_CONTENT;
+ recipe.featureIds = [feature];
+ await ExperimentTestUtils.validateExperiment(recipe);
+ return recipe;
+};
+
+const getCFRExperiment = async () => {
+ return getExperiment("cfr");
+};
+
+const getLegacyCFRExperiment = async () => {
+ let recipe = ExperimentFakes.recipe(`test_xman_cfr_${Date.now()}`, {
+ id: "xman_test_message",
+ bucketConfig: {
+ count: 100,
+ start: 0,
+ total: 100,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ });
+
+ delete recipe.branches[0].features;
+ delete recipe.branches[1].features;
+ recipe.branches[0].feature = {
+ featureId: "cfr",
+ value: MESSAGE_CONTENT,
+ };
+ recipe.branches[1].feature = {
+ featureId: "cfr",
+ value: MESSAGE_CONTENT,
+ };
+ return recipe;
+};
+
+const client = RemoteSettings("nimbus-desktop-experiments");
+
+// no `add_task` because we want to run this setup before each test not before
+// the entire test suite.
+async function setup(experiment) {
+ // Store the experiment in RS local db to bypass synchronization.
+ await client.db.importChanges({}, Date.now(), [experiment], { clear: true });
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["app.shield.optoutstudies.enabled", true],
+ ["datareporting.healthreport.uploadEnabled", true],
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments",
+ `{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+}
+
+async function cleanup() {
+ await client.db.clear();
+ await SpecialPowers.popPrefEnv();
+ // Reload the provider
+ await ASRouter._updateMessageProviders();
+}
+
+/**
+ * Assert that a message is (or optionally is not) present in the ASRouter
+ * messages list, optionally waiting for it to be present/not present.
+ * @param {string} id message id
+ * @param {boolean} [found=true] expect the message to be found
+ * @param {boolean} [wait=true] check for the message until found/not found
+ * @returns {Promise<Message|null>} resolves with the message, if found
+ */
+async function assertMessageInState(id, found = true, wait = true) {
+ if (wait) {
+ await BrowserTestUtils.waitForCondition(
+ () => !!ASRouter.state.messages.find(m => m.id === id) === found,
+ `Message ${id} should ${found ? "" : "not"} be found in ASRouter state`
+ );
+ }
+ const message = ASRouter.state.messages.find(m => m.id === id);
+ Assert.equal(
+ !!message,
+ found,
+ `Message ${id} should ${found ? "" : "not"} be found`
+ );
+ return message || null;
+}
+
+add_task(async function test_loading_experimentsAPI() {
+ const experiment = await getCFRExperiment();
+ await setup(experiment);
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ const telemetryFeedInstance = new TelemetryFeed();
+ Assert.ok(
+ telemetryFeedInstance.isInCFRCohort,
+ "Telemetry should return true"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_loading_fxms_message_1_feature() {
+ const experiment = await getExperiment("fxms-message-1");
+ await setup(experiment);
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "fxms-message-1" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_loading_experimentsAPI_legacy() {
+ const experiment = await getLegacyCFRExperiment();
+ await setup(experiment);
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ const telemetryFeedInstance = new TelemetryFeed();
+ Assert.ok(
+ telemetryFeedInstance.isInCFRCohort,
+ "Telemetry should return true"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_loading_experimentsAPI_rollout() {
+ const rollout = await getCFRExperiment();
+ rollout.isRollout = true;
+ rollout.branches.pop();
+
+ await setup(rollout);
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(() =>
+ ExperimentAPI.getRolloutMetaData({ featureId: "cfr" })
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_exposure_ping() {
+ // Reset this check to allow sending multiple exposure pings in tests
+ NimbusFeatures.cfr._didSendExposureEvent = false;
+ const experiment = await getCFRExperiment();
+ await setup(experiment);
+ Services.telemetry.clearScalars();
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ const exposureSpy = sinon.spy(ExperimentAPI, "recordExposureEvent");
+
+ await ASRouter.sendTriggerMessage({
+ tabId: 1,
+ browser: gBrowser.selectedBrowser,
+ id: "openURL",
+ param: { host: "messenger.com" },
+ });
+
+ Assert.ok(exposureSpy.callCount === 1, "Should send exposure ping");
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ "telemetry.event_counts",
+ "normandy#expose#nimbus_experiment",
+ 1
+ );
+
+ exposureSpy.restore();
+ await cleanup();
+});
+
+add_task(async function test_exposure_ping_legacy() {
+ // Reset this check to allow sending multiple exposure pings in tests
+ NimbusFeatures.cfr._didSendExposureEvent = false;
+ const experiment = await getLegacyCFRExperiment();
+ await setup(experiment);
+ Services.telemetry.clearScalars();
+ // Fetch the new recipe from RS
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await assertMessageInState("xman_test_message");
+
+ const exposureSpy = sinon.spy(ExperimentAPI, "recordExposureEvent");
+
+ await ASRouter.sendTriggerMessage({
+ tabId: 1,
+ browser: gBrowser.selectedBrowser,
+ id: "openURL",
+ param: { host: "messenger.com" },
+ });
+
+ Assert.ok(exposureSpy.callCount === 1, "Should send exposure ping");
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ "telemetry.event_counts",
+ "normandy#expose#nimbus_experiment",
+ 1
+ );
+
+ exposureSpy.restore();
+ await cleanup();
+});
+
+add_task(async function test_forceEnrollUpdatesMessages() {
+ const experiment = await getCFRExperiment();
+
+ await setup(experiment);
+ await SpecialPowers.pushPrefEnv({
+ set: [["nimbus.debug", true]],
+ });
+
+ await assertMessageInState("xman_test_message", false, false);
+
+ await RemoteSettingsExperimentLoader.optInToExperiment({
+ slug: experiment.slug,
+ branch: experiment.branches[0].slug,
+ });
+
+ await assertMessageInState("xman_test_message");
+
+ await ExperimentManager.unenroll(`optin-${experiment.slug}`, "cleanup");
+ await SpecialPowers.popPrefEnv();
+ await cleanup();
+});
+
+add_task(async function test_update_on_enrollments_changed() {
+ // Check that the message is not already present
+ await assertMessageInState("xman_test_message", false, false);
+
+ const experiment = await getCFRExperiment();
+ let enrollmentChanged = TestUtils.topicObserved("nimbus:enrollments-updated");
+ await setup(experiment);
+ await RemoteSettingsExperimentLoader.updateRecipes();
+
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+ await enrollmentChanged;
+
+ await assertMessageInState("xman_test_message");
+
+ await cleanup();
+});
+
+add_task(async function test_emptyMessage() {
+ const experiment = ExperimentFakes.recipe(`empty_${Date.now()}`, {
+ id: "empty",
+ branches: [
+ {
+ slug: "a",
+ ratio: 1,
+ features: [
+ {
+ featureId: "cfr",
+ value: {},
+ },
+ ],
+ },
+ ],
+ bucketConfig: {
+ start: 0,
+ count: 100,
+ total: 100,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ });
+
+ await setup(experiment);
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId: "cfr" }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await ASRouter._updateMessageProviders();
+
+ const experimentsProvider = ASRouter.state.providers.find(
+ p => p.id === "messaging-experiments"
+ );
+
+ // Clear all messages
+ ASRouter.setState(state => ({
+ messages: [],
+ }));
+
+ await ASRouter.loadMessagesFromAllProviders([experimentsProvider]);
+
+ Assert.deepEqual(
+ ASRouter.state.messages,
+ [],
+ "ASRouter should have loaded zero messages"
+ );
+
+ await cleanup();
+});
+
+add_task(async function test_multiMessageTreatment() {
+ const featureId = "cfr";
+ // Add an array of two messages to the first branch
+ const messages = [
+ { ...MESSAGE_CONTENT, id: "multi-message-1" },
+ { ...MESSAGE_CONTENT, id: "multi-message-2" },
+ ];
+ const recipe = ExperimentFakes.recipe(`multi-message_${Date.now()}`, {
+ id: `multi-message`,
+ bucketConfig: {
+ count: 100,
+ start: 0,
+ total: 100,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ branches: [
+ {
+ slug: "control",
+ ratio: 1,
+ features: [{ featureId, value: { template: "multi", messages } }],
+ },
+ ],
+ });
+ await ExperimentTestUtils.validateExperiment(recipe);
+
+ await setup(recipe);
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ messages
+ .map(m => ASRouter.state.messages.find(n => n.id === m.id))
+ .every(Boolean),
+ "Experiment message found in ASRouter state"
+ );
+ Assert.ok(true, "Experiment message found in ASRouter state");
+
+ await cleanup();
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_group_frequency.js b/browser/components/newtab/test/browser/browser_asrouter_group_frequency.js
new file mode 100644
index 0000000000..5957a5905e
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_group_frequency.js
@@ -0,0 +1,190 @@
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/CFRMessageProvider.sys.mjs"
+);
+const { CFRPageActions } = ChromeUtils.import(
+ "resource://activity-stream/lib/CFRPageActions.jsm"
+);
+
+/**
+ * Load and modify a message for the test.
+ */
+add_setup(async function () {
+ const initialMsgCount = ASRouter.state.messages.length;
+ const heartbeatMsg = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "HEARTBEAT_TACTIC_2"
+ );
+ const testMessage = {
+ ...heartbeatMsg,
+ groups: ["messaging-experiments"],
+ targeting: "true",
+ // Ensure no overlap due to frequency capping with other tests
+ id: `HEARTBEAT_MESSAGE_${Date.now()}`,
+ };
+ const client = RemoteSettings("cfr");
+ await client.db.importChanges({}, Date.now(), [testMessage], {
+ clear: true,
+ });
+
+ // Force the CFR provider cache to 0 by modifying updateCycleInMs
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length > initialMsgCount,
+ "Should load the extra heartbeat message"
+ );
+
+ BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.find(m => m.id === testMessage.id),
+ "Wait to load the message"
+ );
+
+ const msg = ASRouter.state.messages.find(m => m.id === testMessage.id);
+ Assert.equal(msg.targeting, "true");
+ Assert.equal(msg.groups[0], "messaging-experiments");
+
+ registerCleanupFunction(async () => {
+ await client.db.clear();
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length === initialMsgCount,
+ "Should reset messages"
+ );
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+/**
+ * Test group frequency capping.
+ * Message has a lifetime frequency of 3 but it's group has a lifetime frequency
+ * of 2. It should only show up twice.
+ * We update the provider to remove any daily limitations so it should show up
+ * on every new tab load.
+ */
+add_task(async function test_heartbeat_tactic_2() {
+ const TEST_URL = "http://example.com";
+ const msg = ASRouter.state.messages.find(m =>
+ m.groups.includes("messaging-experiments")
+ );
+ Assert.ok(msg, "Message found");
+ const groupConfiguration = {
+ id: "messaging-experiments",
+ enabled: true,
+ frequency: { lifetime: 2 },
+ };
+ const client = RemoteSettings("message-groups");
+ await client.db.importChanges({}, Date.now(), [groupConfiguration], {
+ clear: true,
+ });
+
+ // Force the WNPanel provider cache to 0 by modifying updateCycleInMs
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.message-groups",
+ `{"id":"message-groups","enabled":true,"type":"remote-settings","collection":"message-groups","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ const msgs = await client.get();
+ return msgs.find(m => m.id === groupConfiguration.id);
+ }, "Wait for RS message");
+
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadAllMessageGroups();
+
+ let groupState = await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.groups.find(g => g.id === groupConfiguration.id),
+ "Wait for group config to load"
+ );
+ Assert.ok(groupState, "Group config found");
+ Assert.ok(groupState.enabled, "Group is enabled");
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.loadURIString(tab1.linkedBrowser, TEST_URL);
+
+ let chiclet = document.getElementById("contextual-feature-recommendation");
+ Assert.ok(chiclet, "CFR chiclet element found (tab1)");
+ await BrowserTestUtils.waitForCondition(
+ () => !chiclet.hidden,
+ "Chiclet should be visible (tab1)"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ ASRouter.state.messageImpressions[msg.id] &&
+ ASRouter.state.messageImpressions[msg.id].length === 1,
+ "First impression recorded"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.loadURIString(tab2.linkedBrowser, TEST_URL);
+
+ Assert.ok(chiclet, "CFR chiclet element found (tab2)");
+ await BrowserTestUtils.waitForCondition(
+ () => !chiclet.hidden,
+ "Chiclet should be visible (tab2)"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ ASRouter.state.messageImpressions[msg.id] &&
+ ASRouter.state.messageImpressions[msg.id].length === 2,
+ "Second impression recorded"
+ );
+
+ Assert.ok(
+ !ASRouter.isBelowFrequencyCaps(msg),
+ "Should have reached freq limit"
+ );
+
+ BrowserTestUtils.removeTab(tab2);
+
+ let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.loadURIString(tab3.linkedBrowser, TEST_URL);
+
+ await BrowserTestUtils.waitForCondition(
+ () => chiclet.hidden,
+ "Heartbeat button should be hidden"
+ );
+ Assert.equal(
+ ASRouter.state.messageImpressions[msg.id] &&
+ ASRouter.state.messageImpressions[msg.id].length,
+ 2,
+ "Number of impressions did not increase"
+ );
+
+ BrowserTestUtils.removeTab(tab3);
+
+ info("Cleanup");
+ await client.db.clear();
+ // Reset group impressions
+ await ASRouter.resetGroupsState();
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await SpecialPowers.popPrefEnv();
+ CFRPageActions.clearRecommendations();
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_group_userprefs.js b/browser/components/newtab/test/browser/browser_asrouter_group_userprefs.js
new file mode 100644
index 0000000000..af943b8587
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_group_userprefs.js
@@ -0,0 +1,160 @@
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/CFRMessageProvider.sys.mjs"
+);
+const { CFRPageActions } = ChromeUtils.import(
+ "resource://activity-stream/lib/CFRPageActions.jsm"
+);
+
+/**
+ * Load and modify a message for the test.
+ */
+add_setup(async function () {
+ const initialMsgCount = ASRouter.state.messages.length;
+ const heartbeatMsg = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "HEARTBEAT_TACTIC_2"
+ );
+ const testMessage = {
+ ...heartbeatMsg,
+ groups: ["messaging-experiments"],
+ targeting: "true",
+ // Ensure no overlap due to frequency capping with other tests
+ id: `HEARTBEAT_MESSAGE_${Date.now()}`,
+ };
+ const client = RemoteSettings("cfr");
+ await client.db.importChanges({}, Date.now(), [testMessage], { clear: true });
+
+ // Force the CFR provider cache to 0 by modifying updateCycleInMs
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length > initialMsgCount,
+ "Should load the extra heartbeat message"
+ );
+
+ BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.find(m => m.id === testMessage.id),
+ "Wait to load the message"
+ );
+
+ const msg = ASRouter.state.messages.find(m => m.id === testMessage.id);
+ Assert.equal(msg.targeting, "true");
+ Assert.equal(msg.groups[0], "messaging-experiments");
+
+ registerCleanupFunction(async () => {
+ await client.db.clear();
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length === initialMsgCount,
+ "Should reset messages"
+ );
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+/**
+ * Test group user preferences.
+ * Group is enabled if both user preferences are enabled.
+ */
+add_task(async function test_heartbeat_tactic_2() {
+ const TEST_URL = "http://example.com";
+ const msg = ASRouter.state.messages.find(m =>
+ m.groups.includes("messaging-experiments")
+ );
+ Assert.ok(msg, "Message found");
+ const groupConfiguration = {
+ id: "messaging-experiments",
+ enabled: true,
+ userPreferences: ["browser.userPreference.messaging-experiments"],
+ };
+ const client = RemoteSettings("message-groups");
+ await client.db.importChanges({}, Date.now(), [groupConfiguration], {
+ clear: true,
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.message-groups",
+ `{"id":"message-groups","enabled":true,"type":"remote-settings","collection":"message-groups","updateCycleInMs":0}`,
+ ],
+ ["browser.userPreference.messaging-experiments", true],
+ ],
+ });
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ const msgs = await client.get();
+ return msgs.find(m => m.id === groupConfiguration.id);
+ }, "Wait for RS message");
+
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadAllMessageGroups();
+
+ let groupState = await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.groups.find(g => g.id === groupConfiguration.id),
+ "Wait for group config to load"
+ );
+ Assert.ok(groupState, "Group config found");
+ Assert.ok(groupState.enabled, "Group is enabled");
+ Assert.ok(ASRouter.isUnblockedMessage(msg), "Message is unblocked");
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.loadURIString(tab1.linkedBrowser, TEST_URL);
+
+ let chiclet = document.getElementById("contextual-feature-recommendation");
+ Assert.ok(chiclet, "CFR chiclet element found");
+ await BrowserTestUtils.waitForCondition(
+ () => !chiclet.hidden,
+ "Chiclet should be visible (userprefs enabled)"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.userPreference.messaging-experiments", false]],
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ ASRouter.state.groups.find(
+ g => g.id === groupConfiguration.id && !g.enable
+ ),
+ "Wait for group config to load"
+ );
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ BrowserTestUtils.loadURIString(tab2.linkedBrowser, TEST_URL);
+
+ await BrowserTestUtils.waitForCondition(
+ () => chiclet.hidden,
+ "Heartbeat button should not be visible (userprefs disabled)"
+ );
+
+ info("Cleanup");
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ await client.db.clear();
+ // Reset group impressions
+ await ASRouter.resetGroupsState();
+ // Reload the providers
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ await SpecialPowers.popPrefEnv();
+ CFRPageActions.clearRecommendations();
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_infobar.js b/browser/components/newtab/test/browser/browser_asrouter_infobar.js
new file mode 100644
index 0000000000..dbbc86bb3a
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_infobar.js
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { InfoBar } = ChromeUtils.import(
+ "resource://activity-stream/lib/InfoBar.jsm"
+);
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/CFRMessageProvider.sys.mjs"
+);
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { BrowserWindowTracker } = ChromeUtils.import(
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+
+add_task(async function show_and_send_telemetry() {
+ let message = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "INFOBAR_ACTION_86"
+ );
+
+ Assert.ok(message.id, "Found the message");
+
+ let dispatchStub = sinon.stub();
+ let infobar = InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ {
+ ...message,
+ content: {
+ priority: window.gNotificationBox.PRIORITY_WARNING_HIGH,
+ ...message.content,
+ },
+ },
+ dispatchStub
+ );
+
+ Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION");
+ // This is the call to increment impressions for frequency capping
+ Assert.equal(dispatchStub.firstCall.args[0].type, "IMPRESSION");
+ Assert.equal(dispatchStub.firstCall.args[0].data.id, message.id);
+ // This is the telemetry ping
+ Assert.equal(dispatchStub.secondCall.args[0].data.event, "IMPRESSION");
+ Assert.equal(dispatchStub.secondCall.args[0].data.message_id, message.id);
+ Assert.equal(
+ infobar.notification.priority,
+ window.gNotificationBox.PRIORITY_WARNING_HIGH,
+ "Has the priority level set in the message definition"
+ );
+
+ let primaryBtn = infobar.notification.buttonContainer.querySelector(
+ ".notification-button.primary"
+ );
+
+ Assert.ok(primaryBtn, "Has a primary button");
+ primaryBtn.click();
+
+ Assert.equal(dispatchStub.callCount, 4, "Called again with CLICK + removed");
+ Assert.equal(dispatchStub.thirdCall.args[0].type, "USER_ACTION");
+ Assert.equal(
+ dispatchStub.lastCall.args[0].data.event,
+ "CLICK_PRIMARY_BUTTON"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => !InfoBar._activeInfobar,
+ "Wait for notification to be dismissed by primary btn click."
+ );
+});
+
+add_task(async function react_to_trigger() {
+ let message = {
+ ...(await CFRMessageProvider.getMessages()).find(
+ m => m.id === "INFOBAR_ACTION_86"
+ ),
+ };
+ message.targeting = "true";
+ message.content.type = "tab";
+ message.groups = [];
+ message.provider = ASRouter.state.providers[0].id;
+ message.content.message = "Infobar Mochitest";
+ await ASRouter.setState({ messages: [message] });
+
+ let notificationStack = gBrowser.getNotificationBox(gBrowser.selectedBrowser);
+ Assert.ok(
+ !notificationStack.currentNotification,
+ "No notification to start with"
+ );
+
+ await ASRouter.sendTriggerMessage({
+ browser: BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ id: "defaultBrowserCheck",
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () => notificationStack.currentNotification,
+ "Wait for notification to show"
+ );
+
+ Assert.equal(
+ notificationStack.currentNotification.getAttribute("value"),
+ message.id,
+ "Notification id should match"
+ );
+
+ let defaultPriority = notificationStack.PRIORITY_SYSTEM;
+ Assert.ok(
+ notificationStack.currentNotification.priority === defaultPriority,
+ "Notification has default priority"
+ );
+ // Dismiss the notification
+ notificationStack.currentNotification.closeButton.click();
+});
+
+add_task(async function dismiss_telemetry() {
+ let message = {
+ ...(await CFRMessageProvider.getMessages()).find(
+ m => m.id === "INFOBAR_ACTION_86"
+ ),
+ };
+ message.content.type = "tab";
+
+ let dispatchStub = sinon.stub();
+ let infobar = InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+
+ // Remove any IMPRESSION pings
+ dispatchStub.reset();
+
+ infobar.notification.closeButton.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () => infobar.notification === null,
+ "Set to null by `removed` event"
+ );
+
+ Assert.equal(dispatchStub.callCount, 1, "Only called once");
+ Assert.equal(
+ dispatchStub.firstCall.args[0].data.event,
+ "DISMISSED",
+ "Called with dismissed"
+ );
+
+ // Remove DISMISSED ping
+ dispatchStub.reset();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ infobar = InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => dispatchStub.callCount > 0,
+ "Wait for impression ping"
+ );
+
+ // Remove IMPRESSION ping
+ dispatchStub.reset();
+ BrowserTestUtils.removeTab(tab);
+
+ await BrowserTestUtils.waitForCondition(
+ () => infobar.notification === null,
+ "Set to null by `disconnect` event"
+ );
+
+ // Called by closing the tab and triggering "disconnect"
+ Assert.equal(dispatchStub.callCount, 1, "Only called once");
+ Assert.equal(
+ dispatchStub.firstCall.args[0].data.event,
+ "DISMISSED",
+ "Called with dismissed"
+ );
+});
+
+add_task(async function prevent_multiple_messages() {
+ let message = (await CFRMessageProvider.getMessages()).find(
+ m => m.id === "INFOBAR_ACTION_86"
+ );
+
+ Assert.ok(message.id, "Found the message");
+
+ let dispatchStub = sinon.stub();
+ let infobar = InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+
+ Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION");
+
+ // Try to stack 2 notifications
+ InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+
+ Assert.equal(dispatchStub.callCount, 2, "Impression count did not increase");
+
+ // Dismiss the first notification
+ infobar.notification.closeButton.click();
+ Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification");
+
+ // Reset impressions count
+ dispatchStub.reset();
+ // Try show the message again
+ infobar = InfoBar.showInfoBarMessage(
+ BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
+ message,
+ dispatchStub
+ );
+ Assert.ok(InfoBar._activeInfobar, "activeInfobar is set");
+ Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION");
+ // Dismiss the notification again
+ infobar.notification.closeButton.click();
+ Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification");
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_momentspagehub.js b/browser/components/newtab/test/browser/browser_asrouter_momentspagehub.js
new file mode 100644
index 0000000000..44288c1433
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_momentspagehub.js
@@ -0,0 +1,116 @@
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/PanelTestProvider.sys.mjs"
+);
+const { MomentsPageHub } = ChromeUtils.import(
+ "resource://activity-stream/lib/MomentsPageHub.jsm"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once";
+
+add_task(async function test_with_rs_messages() {
+ // Force the WNPanel provider cache to 0 by modifying updateCycleInMs
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.whats-new-panel",
+ `{"id":"cfr","enabled":true,"type":"remote-settings","collection":"cfr","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+ const [msg] = (await PanelTestProvider.getMessages()).filter(
+ ({ template }) => template === "update_action"
+ );
+ const initialMessageCount = ASRouter.state.messages.length;
+ const client = RemoteSettings("cfr");
+ await client.db.importChanges(
+ {},
+ Date.now(),
+ [
+ {
+ // Modify targeting and randomize message name to work around the message
+ // getting blocked (for --verify)
+ ...msg,
+ id: `MOMENTS_MOCHITEST_${Date.now()}`,
+ targeting: "true",
+ },
+ ],
+ { clear: true }
+ );
+ // Reload the provider
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+ // Wait to load the WNPanel messages
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length > initialMessageCount,
+ "Messages did not load"
+ );
+
+ await MomentsPageHub.messageRequest({
+ triggerId: "momentsUpdate",
+ template: "update_action",
+ });
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "").length;
+ }, "Pref value was not set");
+
+ let value = Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "");
+ is(JSON.parse(value).url, msg.content.action.data.url, "Correct value set");
+
+ // Insert a new message and test that priority override works as expected
+ msg.content.action.data.url = "https://www.mozilla.org/#mochitest";
+ await client.db.create(
+ // Modify targeting to ensure the messages always show up
+ {
+ ...msg,
+ id: `MOMENTS_MOCHITEST_${Date.now()}`,
+ priority: 2,
+ targeting: "true",
+ }
+ );
+
+ // Reset so we can `await` for the pref value to be set again
+ Services.prefs.clearUserPref(HOMEPAGE_OVERRIDE_PREF);
+
+ let prevLength = ASRouter.state.messages.length;
+ // Wait to load the messages
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length > prevLength,
+ "Messages did not load"
+ );
+
+ await MomentsPageHub.messageRequest({
+ triggerId: "momentsUpdate",
+ template: "update_action",
+ });
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "").length;
+ }, "Pref value was not set");
+
+ value = Services.prefs.getStringPref(HOMEPAGE_OVERRIDE_PREF, "");
+ is(
+ JSON.parse(value).url,
+ msg.content.action.data.url,
+ "Correct value set for higher priority message"
+ );
+
+ await client.db.clear();
+ // Wait to reset the WNPanel messages from state
+ const previousMessageCount = ASRouter.state.messages.length;
+ await ASRouter.loadMessagesFromAllProviders();
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.length < previousMessageCount,
+ "ASRouter messages should have been removed"
+ );
+ await SpecialPowers.popPrefEnv();
+ // Reload the provider
+ await ASRouter._updateMessageProviders();
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_snippets.js b/browser/components/newtab/test/browser/browser_asrouter_snippets.js
new file mode 100644
index 0000000000..50f3f147dc
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_snippets.js
@@ -0,0 +1,190 @@
+"use strict";
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+const { TelemetryFeed } = ChromeUtils.import(
+ "resource://activity-stream/lib/TelemetryFeed.jsm"
+);
+
+add_task(async function render_below_search_snippet() {
+ ASRouter._validPreviewEndpoint = () => true;
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:newtab?endpoint=https://example.com/browser/browser/components/newtab/test/browser/snippet_below_search_test.json",
+ },
+ async browser => {
+ await waitForPreloaded(browser);
+
+ const complete = await SpecialPowers.spawn(browser, [], async () => {
+ // Verify the simple_below_search_snippet renders in container below searchbox
+ // and nothing is rendered in the footer.
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ ".below-search-snippet .SimpleBelowSearchSnippet"
+ ),
+ "Should find the snippet inside the below search container"
+ );
+
+ is(
+ 0,
+ content.document.querySelector("#footer-asrouter-container")
+ .childNodes.length,
+ "Should not find any snippets in the footer container"
+ );
+
+ return true;
+ });
+
+ Assert.ok(complete, "Test complete.");
+ }
+ );
+});
+
+add_task(async function render_snippets_icon_and_link() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:newtab?endpoint=https://example.com/browser/browser/components/newtab/test/browser/snippet_simple_test.json",
+ },
+ async browser => {
+ await waitForPreloaded(browser);
+
+ const complete = await SpecialPowers.spawn(browser, [], async () => {
+ const syncLink = "https://www.mozilla.org/en-US/firefox/accounts";
+ // Verify the simple_snippet renders in the footer and the container below
+ // searchbox is not rendered.
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "#footer-asrouter-container .SimpleSnippet"
+ ),
+ "Should find the snippet inside the footer container"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "#footer-asrouter-container .SimpleSnippet .icon"
+ ),
+ "Should render an icon"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ `#footer-asrouter-container .SimpleSnippet a[href='${syncLink}']`
+ ),
+ "Should render an anchor with the correct href"
+ );
+
+ ok(
+ !content.document.querySelector(".below-search-snippet"),
+ "Should not find any snippets below search"
+ );
+
+ return true;
+ });
+
+ Assert.ok(complete, "Test complete.");
+ }
+ );
+});
+
+add_task(async function render_preview_snippet() {
+ ASRouter._validPreviewEndpoint = () => true;
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:newtab?endpoint=https://example.com/browser/browser/components/newtab/test/browser/snippet.json",
+ },
+ async browser => {
+ let text = await SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".activity-stream"),
+ `Should render Activity Stream`
+ );
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "#footer-asrouter-container .SimpleSnippet"
+ ),
+ "Should find the snippet inside the footer container"
+ );
+
+ return content.document.querySelector(
+ "#footer-asrouter-container .SimpleSnippet"
+ ).innerText;
+ });
+
+ Assert.equal(
+ text,
+ "On January 30th Nightly will introduce dedicated profiles, making it simpler to run different installations of Firefox side by side. Learn what this means for you.",
+ "Snippet content match"
+ );
+ }
+ );
+});
+
+add_task(async function test_snippets_telemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.snippets",
+ `{"id":"snippets","enabled":true,"type":"remote","url":"https://example.com/browser/browser/components/newtab/test/browser/snippet.json","updateCycleInMs":0}`,
+ ],
+ ["browser.newtabpage.activity-stream.feeds.snippets", true],
+ ],
+ });
+ const sendPingStub = sinon.stub(
+ TelemetryFeed.prototype,
+ "sendStructuredIngestionEvent"
+ );
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ // Work around any issues caching might introduce by navigating to
+ // about blank first
+ url: "about:blank",
+ },
+ async browser => {
+ await BrowserTestUtils.loadURIString(browser, "about:home");
+ await BrowserTestUtils.browserLoaded(browser);
+ let text = await SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".activity-stream"),
+ `Should render Activity Stream`
+ );
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "#footer-asrouter-container .SimpleSnippet"
+ ),
+ "Should find the snippet inside the footer container"
+ );
+
+ return content.document.querySelector(
+ "#footer-asrouter-container .SimpleSnippet"
+ ).innerText;
+ });
+
+ Assert.equal(
+ text,
+ "On January 30th Nightly will introduce dedicated profiles, making it simpler to run different installations of Firefox side by side. Learn what this means for you.",
+ "Snippet content match"
+ );
+ }
+ );
+
+ Assert.ok(sendPingStub.callCount >= 1, "We registered some pings");
+ const snippetsPing = sendPingStub.args.find(args => args[2] === "snippets");
+ Assert.ok(snippetsPing, "Found the snippets ping");
+ Assert.equal(
+ snippetsPing[0].event,
+ "IMPRESSION",
+ "It's the correct ping type"
+ );
+
+ sendPingStub.restore();
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_snippets_dismiss.js b/browser/components/newtab/test/browser/browser_asrouter_snippets_dismiss.js
new file mode 100644
index 0000000000..fb4387eb1d
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_snippets_dismiss.js
@@ -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/. */
+"use strict";
+
+/**
+ * Snippets endpoint has two snippets that share the same campaign id.
+ * We want to make sure that dismissing the snippet on the first about:newtab
+ * will clear the snippet on the next (preloaded) about:newtab.
+ */
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.snippets",
+ '{"id":"snippets","enabled":true,"type":"remote","url":"https://example.com/browser/browser/components/newtab/test/browser/snippet.json","updateCycleInMs":14400000}',
+ ],
+ ["browser.newtabpage.activity-stream.feeds.snippets", true],
+ // Disable onboarding, this would prevent snippets from showing up
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.onboarding",
+ '{"id":"onboarding","type":"local","localProvider":"OnboardingMessageProvider","enabled":false,"exclude":[]}',
+ ],
+ // Ensure this is true, this is the main behavior we want to test for
+ ["browser.newtab.preload", true],
+ ],
+ });
+}
+
+add_task(async function test_campaign_dismiss() {
+ await setup();
+ let tab1 = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:home",
+ });
+ await ContentTask.spawn(gBrowser.selectedBrowser, {}, async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".activity-stream"),
+ `Should render Activity Stream`
+ );
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "#footer-asrouter-container .SimpleSnippet"
+ ),
+ "Should find the snippet inside the footer container"
+ );
+
+ content.document
+ .querySelector("#footer-asrouter-container .blockButton")
+ .click();
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ !content.document.querySelector(
+ "#footer-asrouter-container .SimpleSnippet"
+ ),
+ "Should wait for the snippet to block"
+ );
+ });
+
+ ok(
+ ASRouter.state.messageBlockList.length,
+ "Should have the campaign blocked"
+ );
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:newtab",
+ // This is important because the newtab is preloaded and doesn't behave
+ // like a regular page load
+ waitForLoad: false,
+ });
+
+ await ContentTask.spawn(gBrowser.selectedBrowser, {}, async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".activity-stream"),
+ `Should render Activity Stream`
+ );
+ let snippet = content.document.querySelector(
+ "#footer-asrouter-container .SimpleSnippet"
+ );
+ Assert.equal(
+ snippet,
+ null,
+ "No snippets shown because campaign is blocked"
+ );
+ });
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ await ASRouter.unblockMessageById(["10533", "10534"]);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_targeting.js b/browser/components/newtab/test/browser/browser_asrouter_targeting.js
new file mode 100644
index 0000000000..21429f5bd3
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_targeting.js
@@ -0,0 +1,1697 @@
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AboutNewTab: "resource:///modules/AboutNewTab.jsm",
+ ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+ HomePage: "resource:///modules/HomePage.jsm",
+ QueryCache: "resource://activity-stream/lib/ASRouterTargeting.jsm",
+});
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
+ AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
+ AttributionCode: "resource:///modules/AttributionCode.sys.mjs",
+ BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs",
+ CFRMessageProvider:
+ "resource://activity-stream/lib/CFRMessageProvider.sys.mjs",
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
+ FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
+ NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+ ShellService: "resource:///modules/ShellService.sys.mjs",
+ TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs",
+});
+
+function sendFormAutofillMessage(name, data) {
+ let actor =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor(
+ "FormAutofill"
+ );
+ return actor.receiveMessage({ name, data });
+}
+
+async function removeAutofillRecords() {
+ let addresses = await sendFormAutofillMessage("FormAutofill:GetRecords", {
+ collectionName: "addresses",
+ });
+ if (addresses.length) {
+ let observePromise = TestUtils.topicObserved(
+ "formautofill-storage-changed"
+ );
+ await sendFormAutofillMessage("FormAutofill:RemoveAddresses", {
+ guids: addresses.map(address => address.guid),
+ });
+ await observePromise;
+ }
+ let creditCards = await sendFormAutofillMessage("FormAutofill:GetRecords", {
+ collectionName: "creditCards",
+ });
+ if (creditCards.length) {
+ let observePromise = TestUtils.topicObserved(
+ "formautofill-storage-changed"
+ );
+ await sendFormAutofillMessage("FormAutofill:RemoveCreditCards", {
+ guids: creditCards.map(cc => cc.guid),
+ });
+ await observePromise;
+ }
+}
+
+// ASRouterTargeting.findMatchingMessage
+add_task(async function find_matching_message() {
+ const messages = [
+ { id: "foo", targeting: "FOO" },
+ { id: "bar", targeting: "!FOO" },
+ ];
+ const context = { FOO: true };
+
+ const match = await ASRouterTargeting.findMatchingMessage({
+ messages,
+ context,
+ });
+
+ is(match, messages[0], "should match and return the correct message");
+});
+
+add_task(async function return_nothing_for_no_matching_message() {
+ const messages = [{ id: "bar", targeting: "!FOO" }];
+ const context = { FOO: true };
+
+ const match = await ASRouterTargeting.findMatchingMessage({
+ messages,
+ context,
+ });
+
+ ok(!match, "should return nothing since no matching message exists");
+});
+
+add_task(async function check_other_error_handling() {
+ let called = false;
+ function onError(...args) {
+ called = true;
+ }
+
+ const messages = [{ id: "foo", targeting: "foo" }];
+ const context = {
+ get foo() {
+ throw new Error("test error");
+ },
+ };
+ const match = await ASRouterTargeting.findMatchingMessage({
+ messages,
+ context,
+ onError,
+ });
+
+ ok(!match, "should return nothing since no valid matching message exists");
+
+ Assert.ok(called, "Attribute error caught");
+});
+
+// ASRouterTargeting.Environment
+add_task(async function check_locale() {
+ ok(
+ Services.locale.appLocaleAsBCP47,
+ "Services.locale.appLocaleAsBCP47 exists"
+ );
+ const message = {
+ id: "foo",
+ targeting: `locale == "${Services.locale.appLocaleAsBCP47}"`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by locale"
+ );
+});
+add_task(async function check_localeLanguageCode() {
+ const currentLanguageCode = Services.locale.appLocaleAsBCP47.substr(0, 2);
+ is(
+ Services.locale.negotiateLanguages(
+ [currentLanguageCode],
+ [Services.locale.appLocaleAsBCP47]
+ )[0],
+ Services.locale.appLocaleAsBCP47,
+ "currentLanguageCode should resolve to the current locale (e.g en => en-US)"
+ );
+ const message = {
+ id: "foo",
+ targeting: `localeLanguageCode == "${currentLanguageCode}"`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by localeLanguageCode"
+ );
+});
+
+add_task(async function checkProfileAgeCreated() {
+ let profileAccessor = await ProfileAge();
+ is(
+ await ASRouterTargeting.Environment.profileAgeCreated,
+ await profileAccessor.created,
+ "should return correct profile age creation date"
+ );
+
+ const message = {
+ id: "foo",
+ targeting: `profileAgeCreated > ${(await profileAccessor.created) - 100}`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by profile age created"
+ );
+});
+
+add_task(async function checkProfileAgeReset() {
+ let profileAccessor = await ProfileAge();
+ is(
+ await ASRouterTargeting.Environment.profileAgeReset,
+ await profileAccessor.reset,
+ "should return correct profile age reset"
+ );
+
+ const message = {
+ id: "foo",
+ targeting: `profileAgeReset == ${await profileAccessor.reset}`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by profile age reset"
+ );
+});
+
+add_task(async function checkCurrentDate() {
+ let message = {
+ id: "foo",
+ targeting: `currentDate < '${new Date(Date.now() + 5000)}'|date`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select message based on currentDate < timestamp"
+ );
+
+ message = {
+ id: "foo",
+ targeting: `currentDate > '${new Date(Date.now() - 5000)}'|date`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select message based on currentDate > timestamp"
+ );
+});
+
+add_task(async function check_usesFirefoxSync() {
+ await pushPrefs(["services.sync.username", "someone@foo.com"]);
+ is(
+ await ASRouterTargeting.Environment.usesFirefoxSync,
+ true,
+ "should return true if a fx account is set"
+ );
+
+ const message = { id: "foo", targeting: "usesFirefoxSync" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by usesFirefoxSync"
+ );
+});
+
+add_task(async function check_isFxAEnabled() {
+ await pushPrefs(["identity.fxaccounts.enabled", false]);
+ is(
+ await ASRouterTargeting.Environment.isFxAEnabled,
+ false,
+ "should return false if fxa is disabled"
+ );
+
+ const message = { id: "foo", targeting: "isFxAEnabled" };
+ ok(
+ !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })),
+ "should not select a message if fxa is disabled"
+ );
+});
+
+add_task(async function check_isFxAEnabled() {
+ await pushPrefs(["identity.fxaccounts.enabled", true]);
+ is(
+ await ASRouterTargeting.Environment.isFxAEnabled,
+ true,
+ "should return true if fxa is enabled"
+ );
+
+ const message = { id: "foo", targeting: "isFxAEnabled" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select the correct message"
+ );
+});
+
+add_task(async function check_isFxASignedIn_false() {
+ await pushPrefs(
+ ["identity.fxaccounts.enabled", true],
+ ["services.sync.username", ""]
+ );
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => sandbox.restore());
+ sandbox.stub(FxAccounts.prototype, "getSignedInUser").resolves(null);
+ is(
+ await ASRouterTargeting.Environment.isFxASignedIn,
+ false,
+ "user should not appear signed in"
+ );
+
+ const message = { id: "foo", targeting: "isFxASignedIn" };
+ isnot(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should not select the message since user is not signed in"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function check_isFxASignedIn_true() {
+ await pushPrefs(
+ ["identity.fxaccounts.enabled", true],
+ ["services.sync.username", ""]
+ );
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => sandbox.restore());
+ sandbox.stub(FxAccounts.prototype, "getSignedInUser").resolves({});
+ is(
+ await ASRouterTargeting.Environment.isFxASignedIn,
+ true,
+ "user should appear signed in"
+ );
+
+ const message = { id: "foo", targeting: "isFxASignedIn" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select the correct message"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function check_totalBookmarksCount() {
+ // Make sure we remove default bookmarks so they don't interfere
+ await clearHistoryAndBookmarks();
+ const message = { id: "foo", targeting: "totalBookmarksCount > 0" };
+
+ const results = await ASRouterTargeting.findMatchingMessage({
+ messages: [message],
+ });
+ ok(
+ !(results ? JSON.stringify(results) : results),
+ "Should not select any message because bookmarks count is not 0"
+ );
+
+ const bookmark = await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "foo",
+ url: "https://mozilla1.com/nowNew",
+ });
+
+ QueryCache.queries.TotalBookmarksCount.expire();
+
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "Should select correct item after bookmarks are added."
+ );
+
+ // Cleanup
+ await PlacesUtils.bookmarks.remove(bookmark.guid);
+});
+
+add_task(async function check_needsUpdate() {
+ QueryCache.queries.CheckBrowserNeedsUpdate.setUp(true);
+
+ const message = { id: "foo", targeting: "needsUpdate" };
+
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "Should select message because update count > 0"
+ );
+
+ QueryCache.queries.CheckBrowserNeedsUpdate.setUp(false);
+
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ null,
+ "Should not select message because update count == 0"
+ );
+});
+
+add_task(async function checksearchEngines() {
+ const result = await ASRouterTargeting.Environment.searchEngines;
+ const expectedInstalled = (await Services.search.getAppProvidedEngines())
+ .map(engine => engine.identifier)
+ .sort()
+ .join(",");
+ ok(
+ result.installed.length,
+ "searchEngines.installed should be a non-empty array"
+ );
+ is(
+ result.installed.sort().join(","),
+ expectedInstalled,
+ "searchEngines.installed should be an array of visible search engines"
+ );
+ ok(
+ result.current && typeof result.current === "string",
+ "searchEngines.current should be a truthy string"
+ );
+ is(
+ result.current,
+ (await Services.search.getDefault()).identifier,
+ "searchEngines.current should be the current engine name"
+ );
+
+ const message = {
+ id: "foo",
+ targeting: `searchEngines[.current == ${
+ (await Services.search.getDefault()).identifier
+ }]`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by searchEngines.current"
+ );
+
+ const message2 = {
+ id: "foo",
+ targeting: `searchEngines[${
+ (await Services.search.getAppProvidedEngines())[0].identifier
+ } in .installed]`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message2] }),
+ message2,
+ "should select correct item by searchEngines.installed"
+ );
+});
+
+add_task(async function checkisDefaultBrowser() {
+ const expected = ShellService.isDefaultBrowser();
+ const result = await ASRouterTargeting.Environment.isDefaultBrowser;
+ is(typeof result, "boolean", "isDefaultBrowser should be a boolean value");
+ is(
+ result,
+ expected,
+ "isDefaultBrowser should be equal to ShellService.isDefaultBrowser()"
+ );
+ const message = {
+ id: "foo",
+ targeting: `isDefaultBrowser == ${expected.toString()}`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by isDefaultBrowser"
+ );
+});
+
+add_task(async function checkdevToolsOpenedCount() {
+ await pushPrefs(["devtools.selfxss.count", 5]);
+ is(
+ ASRouterTargeting.Environment.devToolsOpenedCount,
+ 5,
+ "devToolsOpenedCount should be equal to devtools.selfxss.count pref value"
+ );
+ const message = { id: "foo", targeting: "devToolsOpenedCount >= 5" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by devToolsOpenedCount"
+ );
+});
+
+add_task(async function check_platformName() {
+ const message = {
+ id: "foo",
+ targeting: `platformName == "${AppConstants.platform}"`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by platformName"
+ );
+});
+
+AddonTestUtils.initMochitest(this);
+
+add_task(async function checkAddonsInfo() {
+ const FAKE_ID = "testaddon@tests.mozilla.org";
+ const FAKE_NAME = "Test Addon";
+ const FAKE_VERSION = "0.5.7";
+
+ const xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: FAKE_ID } },
+ name: FAKE_NAME,
+ version: FAKE_VERSION,
+ },
+ });
+
+ await Promise.all([
+ AddonTestUtils.promiseWebExtensionStartup(FAKE_ID),
+ AddonManager.installTemporaryAddon(xpi),
+ ]);
+
+ const { addons } = await AddonManager.getActiveAddons([
+ "extension",
+ "service",
+ ]);
+
+ const { addons: asRouterAddons, isFullData } = await ASRouterTargeting
+ .Environment.addonsInfo;
+
+ ok(
+ addons.every(({ id }) => asRouterAddons[id]),
+ "should contain every addon"
+ );
+
+ ok(
+ Object.getOwnPropertyNames(asRouterAddons).every(id =>
+ addons.some(addon => addon.id === id)
+ ),
+ "should contain no incorrect addons"
+ );
+
+ const testAddon = asRouterAddons[FAKE_ID];
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "version") &&
+ testAddon.version === FAKE_VERSION,
+ "should correctly provide `version` property"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "type") &&
+ testAddon.type === "extension",
+ "should correctly provide `type` property"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "isSystem") &&
+ testAddon.isSystem === false,
+ "should correctly provide `isSystem` property"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "isWebExtension") &&
+ testAddon.isWebExtension === true,
+ "should correctly provide `isWebExtension` property"
+ );
+
+ // As we installed our test addon the addons database must be initialised, so
+ // (in this test environment) we expect to receive "full" data
+
+ ok(isFullData, "should receive full data");
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "name") &&
+ testAddon.name === FAKE_NAME,
+ "should correctly provide `name` property from full data"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "userDisabled") &&
+ testAddon.userDisabled === false,
+ "should correctly provide `userDisabled` property from full data"
+ );
+
+ ok(
+ Object.prototype.hasOwnProperty.call(testAddon, "installDate") &&
+ Math.abs(Date.now() - new Date(testAddon.installDate)) < 60 * 1000,
+ "should correctly provide `installDate` property from full data"
+ );
+});
+
+add_task(async function checkFrecentSites() {
+ const now = Date.now();
+ const timeDaysAgo = numDays => now - numDays * 24 * 60 * 60 * 1000;
+
+ const visits = [];
+ for (const [uri, count, visitDate] of [
+ ["https://mozilla1.com/", 10, timeDaysAgo(0)], // frecency 1000
+ ["https://mozilla2.com/", 5, timeDaysAgo(1)], // frecency 500
+ ["https://mozilla3.com/", 1, timeDaysAgo(2)], // frecency 100
+ ]) {
+ [...Array(count).keys()].forEach(() =>
+ visits.push({
+ uri,
+ visitDate: visitDate * 1000, // Places expects microseconds
+ })
+ );
+ }
+
+ await PlacesTestUtils.addVisits(visits);
+
+ let message = {
+ id: "foo",
+ targeting: "'mozilla3.com' in topFrecentSites|mapToProperty('host')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by host in topFrecentSites"
+ );
+
+ message = {
+ id: "foo",
+ targeting: "'non-existent.com' in topFrecentSites|mapToProperty('host')",
+ };
+ ok(
+ !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })),
+ "should not select incorrect item by host in topFrecentSites"
+ );
+
+ message = {
+ id: "foo",
+ targeting:
+ "'mozilla2.com' in topFrecentSites[.frecency >= 400]|mapToProperty('host')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by frecency"
+ );
+
+ message = {
+ id: "foo",
+ targeting:
+ "'mozilla2.com' in topFrecentSites[.frecency >= 600]|mapToProperty('host')",
+ };
+ ok(
+ !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })),
+ "should not select incorrect item when filtering by frecency"
+ );
+
+ message = {
+ id: "foo",
+ targeting: `'mozilla2.com' in topFrecentSites[.lastVisitDate >= ${
+ timeDaysAgo(1) - 1
+ }]|mapToProperty('host')`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by lastVisitDate"
+ );
+
+ message = {
+ id: "foo",
+ targeting: `'mozilla2.com' in topFrecentSites[.lastVisitDate >= ${
+ timeDaysAgo(0) - 1
+ }]|mapToProperty('host')`,
+ };
+ ok(
+ !(await ASRouterTargeting.findMatchingMessage({ messages: [message] })),
+ "should not select incorrect item when filtering by lastVisitDate"
+ );
+
+ message = {
+ id: "foo",
+ targeting: `(topFrecentSites[.frecency >= 900 && .lastVisitDate >= ${
+ timeDaysAgo(1) - 1
+ }]|mapToProperty('host') intersect ['mozilla3.com', 'mozilla2.com', 'mozilla1.com'])|length > 0`,
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by frecency and lastVisitDate with multiple candidate domains"
+ );
+
+ // Cleanup
+ await clearHistoryAndBookmarks();
+});
+
+add_task(async function check_pinned_sites() {
+ // 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();
+ const originalPin = JSON.stringify(NewTabUtils.pinnedLinks.links);
+ const sitesToPin = [
+ { url: "https://foo.com" },
+ { url: "https://bloo.com" },
+ { url: "https://floogle.com", searchTopSite: true },
+ ];
+ sitesToPin.forEach(site =>
+ NewTabUtils.pinnedLinks.pin(site, NewTabUtils.pinnedLinks.links.length)
+ );
+
+ // Unpinning adds null to the list of pinned sites, which we should test that we handle gracefully for our targeting
+ NewTabUtils.pinnedLinks.unpin(sitesToPin[1]);
+ ok(
+ NewTabUtils.pinnedLinks.links.includes(null),
+ "should have set an item in pinned links to null via unpinning for testing"
+ );
+
+ let message;
+
+ message = {
+ id: "foo",
+ targeting: "'https://foo.com' in pinnedSites|mapToProperty('url')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by url in pinnedSites"
+ );
+
+ message = {
+ id: "foo",
+ targeting: "'foo.com' in pinnedSites|mapToProperty('host')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by host in pinnedSites"
+ );
+
+ message = {
+ id: "foo",
+ targeting:
+ "'floogle.com' in pinnedSites[.searchTopSite == true]|mapToProperty('host')",
+ };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by host and searchTopSite in pinnedSites"
+ );
+
+ // Cleanup
+ sitesToPin.forEach(site => NewTabUtils.pinnedLinks.unpin(site));
+
+ await clearHistoryAndBookmarks();
+ Services.prefs.clearUserPref("browser.newtabpage.pinned");
+ NewTabUtils.pinnedLinks.resetCache();
+ is(
+ JSON.stringify(NewTabUtils.pinnedLinks.links),
+ originalPin,
+ "should restore pinned sites to its original state"
+ );
+});
+
+add_task(async function check_firefox_version() {
+ const message = { id: "foo", targeting: "firefoxVersion > 0" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by firefox version"
+ );
+});
+
+add_task(async function check_region() {
+ Region._setHomeRegion("DE", false);
+ const message = { id: "foo", targeting: "region in ['DE']" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item when filtering by firefox geo"
+ );
+});
+
+add_task(async function check_browserSettings() {
+ is(
+ await JSON.stringify(ASRouterTargeting.Environment.browserSettings.update),
+ JSON.stringify(TelemetryEnvironment.currentEnvironment.settings.update),
+ "should return correct update info"
+ );
+});
+
+add_task(async function check_sync() {
+ is(
+ await ASRouterTargeting.Environment.sync.desktopDevices,
+ Services.prefs.getIntPref("services.sync.clients.devices.desktop", 0),
+ "should return correct desktopDevices info"
+ );
+ is(
+ await ASRouterTargeting.Environment.sync.mobileDevices,
+ Services.prefs.getIntPref("services.sync.clients.devices.mobile", 0),
+ "should return correct mobileDevices info"
+ );
+ is(
+ await ASRouterTargeting.Environment.sync.totalDevices,
+ Services.prefs.getIntPref("services.sync.numClients", 0),
+ "should return correct mobileDevices info"
+ );
+});
+
+add_task(async function check_provider_cohorts() {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.asrouter.providers.onboarding",
+ JSON.stringify({
+ id: "onboarding",
+ messages: [],
+ enabled: true,
+ cohort: "foo",
+ }),
+ ]);
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.asrouter.providers.cfr",
+ JSON.stringify({ id: "cfr", enabled: true, cohort: "bar" }),
+ ]);
+ is(
+ await ASRouterTargeting.Environment.providerCohorts.onboarding,
+ "foo",
+ "should have cohort foo for onboarding"
+ );
+ is(
+ await ASRouterTargeting.Environment.providerCohorts.cfr,
+ "bar",
+ "should have cohort bar for cfr"
+ );
+});
+
+add_task(async function check_xpinstall_enabled() {
+ // should default to true if pref doesn't exist
+ is(await ASRouterTargeting.Environment.xpinstallEnabled, true);
+ // flip to false, check targeting reflects that
+ await pushPrefs(["xpinstall.enabled", false]);
+ is(await ASRouterTargeting.Environment.xpinstallEnabled, false);
+ // flip to true, check targeting reflects that
+ await pushPrefs(["xpinstall.enabled", true]);
+ is(await ASRouterTargeting.Environment.xpinstallEnabled, true);
+});
+
+add_task(async function check_pinned_tabs() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ is(
+ await ASRouterTargeting.Environment.hasPinnedTabs,
+ false,
+ "No pin tabs yet"
+ );
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ gBrowser.pinTab(tab);
+
+ is(
+ await ASRouterTargeting.Environment.hasPinnedTabs,
+ true,
+ "Should detect pinned tab"
+ );
+
+ gBrowser.unpinTab(tab);
+ }
+ );
+});
+
+add_task(async function check_hasAccessedFxAPanel() {
+ is(
+ await ASRouterTargeting.Environment.hasAccessedFxAPanel,
+ false,
+ "Not accessed yet"
+ );
+
+ await pushPrefs(["identity.fxaccounts.toolbar.accessed", true]);
+
+ is(
+ await ASRouterTargeting.Environment.hasAccessedFxAPanel,
+ true,
+ "Should detect panel access"
+ );
+});
+
+add_task(async function checkCFRFeaturesUserPref() {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ false,
+ ]);
+ is(
+ ASRouterTargeting.Environment.userPrefs.cfrFeatures,
+ false,
+ "cfrFeature should be false according to pref"
+ );
+ const message = { id: "foo", targeting: "userPrefs.cfrFeatures == false" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by cfrFeature"
+ );
+});
+
+add_task(async function checkCFRAddonsUserPref() {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons",
+ false,
+ ]);
+ is(
+ ASRouterTargeting.Environment.userPrefs.cfrAddons,
+ false,
+ "cfrFeature should be false according to pref"
+ );
+ const message = { id: "foo", targeting: "userPrefs.cfrAddons == false" };
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item by cfrAddons"
+ );
+});
+
+add_task(async function check_blockedCountByType() {
+ const message = {
+ id: "foo",
+ targeting:
+ "blockedCountByType.cryptominerCount == 0 && blockedCountByType.socialCount == 0",
+ };
+
+ is(
+ await ASRouterTargeting.findMatchingMessage({ messages: [message] }),
+ message,
+ "should select correct item"
+ );
+});
+
+add_task(async function checkPatternMatches() {
+ const now = Date.now();
+ const timeMinutesAgo = numMinutes => now - numMinutes * 60 * 1000;
+ const messages = [
+ {
+ id: "message_with_pattern",
+ targeting: "true",
+ trigger: { id: "frequentVisits", patterns: ["*://*.github.com/"] },
+ },
+ ];
+ const trigger = {
+ id: "frequentVisits",
+ context: {
+ recentVisits: [
+ { timestamp: timeMinutesAgo(33) },
+ { timestamp: timeMinutesAgo(17) },
+ { timestamp: timeMinutesAgo(1) },
+ ],
+ },
+ param: { host: "github.com", url: "https://gist.github.com" },
+ };
+
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages, trigger })).id,
+ "message_with_pattern",
+ "should select PIN_TAB mesage"
+ );
+});
+
+add_task(async function checkPatternsValid() {
+ const messages = (await CFRMessageProvider.getMessages()).filter(
+ m => m.trigger?.patterns
+ );
+
+ for (const message of messages) {
+ Assert.ok(new MatchPatternSet(message.trigger.patterns));
+ }
+});
+
+add_task(async function check_isChinaRepack() {
+ const prefDefaultBranch = Services.prefs.getDefaultBranch("distribution.");
+ const messages = [
+ { id: "msg_for_china_repack", targeting: "isChinaRepack == true" },
+ { id: "msg_for_everyone_else", targeting: "isChinaRepack == false" },
+ ];
+
+ is(
+ await ASRouterTargeting.Environment.isChinaRepack,
+ false,
+ "Fx w/o partner repack info set is not China repack"
+ );
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages })).id,
+ "msg_for_everyone_else",
+ "should select the message for non China repack users"
+ );
+
+ prefDefaultBranch.setCharPref("id", "MozillaOnline");
+
+ is(
+ await ASRouterTargeting.Environment.isChinaRepack,
+ true,
+ "Fx with `distribution.id` set to `MozillaOnline` is China repack"
+ );
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages })).id,
+ "msg_for_china_repack",
+ "should select the message for China repack users"
+ );
+
+ prefDefaultBranch.setCharPref("id", "Example");
+
+ is(
+ await ASRouterTargeting.Environment.isChinaRepack,
+ false,
+ "Fx with `distribution.id` set to other string is not China repack"
+ );
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages })).id,
+ "msg_for_everyone_else",
+ "should select the message for non China repack users"
+ );
+
+ prefDefaultBranch.deleteBranch("");
+});
+
+add_task(async function check_userId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.normandy.user_id", "foo123"]],
+ });
+ is(
+ await ASRouterTargeting.Environment.userId,
+ "foo123",
+ "should read userID from normandy user id pref"
+ );
+});
+
+add_task(async function check_profileRestartCount() {
+ ok(
+ !isNaN(ASRouterTargeting.Environment.profileRestartCount),
+ "it should return a number"
+ );
+});
+
+add_task(async function check_homePageSettings_default() {
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(settings.isDefault, "should set as default");
+ ok(!settings.isLocked, "should not set as locked");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(!settings.isCustomUrl, "should not be custom URL");
+ is(settings.urls.length, 1, "should be an 1-entry array");
+ is(settings.urls[0].url, "about:home", "should be about:home");
+ is(settings.urls[0].host, "", "should be an empty string");
+});
+
+add_task(async function check_homePageSettings_locked() {
+ const PREF = "browser.startup.homepage";
+ Services.prefs.lockPref(PREF);
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(settings.isDefault, "should set as default");
+ ok(settings.isLocked, "should set as locked");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(!settings.isCustomUrl, "should not be custom URL");
+ is(settings.urls.length, 1, "should be an 1-entry array");
+ is(settings.urls[0].url, "about:home", "should be about:home");
+ is(settings.urls[0].host, "", "should be an empty string");
+ Services.prefs.unlockPref(PREF);
+});
+
+add_task(async function check_homePageSettings_customURL() {
+ await HomePage.set("https://www.google.com");
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(!settings.isLocked, "should set as locked");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(settings.isCustomUrl, "should be custom URL");
+ is(settings.urls.length, 1, "should be an 1-entry array");
+ is(settings.urls[0].url, "https://www.google.com", "should be a custom URL");
+ is(
+ settings.urls[0].host,
+ "google.com",
+ "should be the host name without 'www.'"
+ );
+
+ HomePage.reset();
+});
+
+add_task(async function check_homePageSettings_customURL_multiple() {
+ await HomePage.set("https://www.google.com|https://www.youtube.com");
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(!settings.isLocked, "should not set as locked");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(settings.isCustomUrl, "should be custom URL");
+ is(settings.urls.length, 2, "should be a 2-entry array");
+ is(settings.urls[0].url, "https://www.google.com", "should be a custom URL");
+ is(
+ settings.urls[0].host,
+ "google.com",
+ "should be the host name without 'www.'"
+ );
+ is(settings.urls[1].url, "https://www.youtube.com", "should be a custom URL");
+ is(
+ settings.urls[1].host,
+ "youtube.com",
+ "should be the host name without 'www.'"
+ );
+
+ HomePage.reset();
+});
+
+add_task(async function check_homePageSettings_webExtension() {
+ const extURI =
+ "moz-extension://0d735548-ba3c-aa43-a0e4-7089584fbb53/homepage.html";
+ await HomePage.set(extURI);
+ let settings = ASRouterTargeting.Environment.homePageSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(!settings.isLocked, "should not set as locked");
+ ok(settings.isWebExt, "should be a web extension");
+ ok(!settings.isCustomUrl, "should be custom URL");
+ is(settings.urls.length, 1, "should be an 1-entry array");
+ is(settings.urls[0].url, extURI, "should be a webExtension URI");
+ is(settings.urls[0].host, "", "should be an empty string");
+
+ HomePage.reset();
+});
+
+add_task(async function check_newtabSettings_default() {
+ let settings = ASRouterTargeting.Environment.newtabSettings;
+
+ ok(settings.isDefault, "should set as default");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(!settings.isCustomUrl, "should not be custom URL");
+ is(settings.url, "about:newtab", "should be about:home");
+ is(settings.host, "", "should be an empty string");
+});
+
+add_task(async function check_newTabSettings_customURL() {
+ AboutNewTab.newTabURL = "https://www.google.com";
+ let settings = ASRouterTargeting.Environment.newtabSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(!settings.isWebExt, "should not be web extension");
+ ok(settings.isCustomUrl, "should be custom URL");
+ is(settings.url, "https://www.google.com", "should be a custom URL");
+ is(settings.host, "google.com", "should be the host name without 'www.'");
+
+ AboutNewTab.resetNewTabURL();
+});
+
+add_task(async function check_newTabSettings_webExtension() {
+ const extURI =
+ "moz-extension://0d735548-ba3c-aa43-a0e4-7089584fbb53/homepage.html";
+ AboutNewTab.newTabURL = extURI;
+ let settings = ASRouterTargeting.Environment.newtabSettings;
+
+ ok(!settings.isDefault, "should not be the default");
+ ok(settings.isWebExt, "should not be web extension");
+ ok(!settings.isCustomUrl, "should be custom URL");
+ is(settings.url, extURI, "should be the web extension URI");
+ is(settings.host, "", "should be an empty string");
+
+ AboutNewTab.resetNewTabURL();
+});
+
+add_task(async function check_openUrlTrigger_context() {
+ const message = {
+ ...(await CFRMessageProvider.getMessages()).find(
+ m => m.id === "YOUTUBE_ENHANCE_3"
+ ),
+ targeting: "visitsCount == 3",
+ };
+ const trigger = {
+ id: "openURL",
+ context: { visitsCount: 3 },
+ param: { host: "youtube.com", url: "https://www.youtube.com" },
+ };
+
+ is(
+ (
+ await ASRouterTargeting.findMatchingMessage({
+ messages: [message],
+ trigger,
+ })
+ ).id,
+ message.id,
+ `should select ${message.id} mesage`
+ );
+});
+
+add_task(async function check_is_major_upgrade() {
+ let message = {
+ id: "check_is_major_upgrade",
+ targeting: `isMajorUpgrade != undefined && isMajorUpgrade == ${
+ Cc["@mozilla.org/browser/clh;1"].getService(Ci.nsIBrowserHandler)
+ .majorUpgrade
+ }`,
+ };
+
+ is(
+ (await ASRouterTargeting.findMatchingMessage({ messages: [message] })).id,
+ message.id,
+ "Should select the message"
+ );
+});
+
+add_task(async function check_userMonthlyActivity() {
+ ok(
+ Array.isArray(await ASRouterTargeting.Environment.userMonthlyActivity),
+ "value is an array"
+ );
+});
+
+add_task(async function check_doesAppNeedPin() {
+ is(
+ typeof (await ASRouterTargeting.Environment.doesAppNeedPin),
+ "boolean",
+ "Should return a boolean"
+ );
+});
+
+add_task(async function check_doesAppNeedPrivatePin() {
+ is(
+ typeof (await ASRouterTargeting.Environment.doesAppNeedPrivatePin),
+ "boolean",
+ "Should return a boolean"
+ );
+});
+
+add_task(async function check_isBackgroundTaskMode() {
+ if (!AppConstants.MOZ_BACKGROUNDTASKS) {
+ // `mochitest-browser` suite `add_task` does not yet support
+ // `properties.skip_if`.
+ ok(true, "Skipping because !AppConstants.MOZ_BACKGROUNDTASKS");
+ return;
+ }
+
+ const bts = Cc["@mozilla.org/backgroundtasks;1"].getService(
+ Ci.nsIBackgroundTasks
+ );
+
+ // Pretend that this is a background task.
+ bts.overrideBackgroundTaskNameForTesting("taskName");
+ is(
+ await ASRouterTargeting.Environment.isBackgroundTaskMode,
+ true,
+ "Is in background task mode"
+ );
+ is(
+ await ASRouterTargeting.Environment.backgroundTaskName,
+ "taskName",
+ "Has expected background task name"
+ );
+
+ // Unset, so that subsequent test functions don't see background task mode.
+ bts.overrideBackgroundTaskNameForTesting(null);
+ is(
+ await ASRouterTargeting.Environment.isBackgroundTaskMode,
+ false,
+ "Is not in background task mode"
+ );
+ is(
+ await ASRouterTargeting.Environment.backgroundTaskName,
+ null,
+ "Has no background task name"
+ );
+});
+
+add_task(async function check_userPrefersReducedMotion() {
+ is(
+ typeof (await ASRouterTargeting.Environment.userPrefersReducedMotion),
+ "boolean",
+ "Should return a boolean"
+ );
+});
+
+add_task(async function test_mr2022Holdback() {
+ await ExperimentAPI.ready();
+
+ ok(
+ !ASRouterTargeting.Environment.inMr2022Holdback,
+ "Should not be in holdback (no experiment)"
+ );
+
+ {
+ const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "majorRelease2022",
+ value: {
+ onboarding: true,
+ },
+ });
+
+ ok(
+ !ASRouterTargeting.Environment.inMr2022Holdback,
+ "Should not be in holdback (onboarding = true)"
+ );
+
+ await doExperimentCleanup();
+ }
+
+ {
+ const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "majorRelease2022",
+ value: {
+ onboarding: false,
+ },
+ });
+
+ ok(
+ ASRouterTargeting.Environment.inMr2022Holdback,
+ "Should be in holdback (onboarding = false)"
+ );
+
+ await doExperimentCleanup();
+ }
+});
+
+add_task(async function test_distributionId() {
+ is(
+ ASRouterTargeting.Environment.distributionId,
+ "",
+ "Should return an empty distribution Id"
+ );
+
+ Services.prefs.getDefaultBranch(null).setCharPref("distribution.id", "test");
+
+ is(
+ ASRouterTargeting.Environment.distributionId,
+ "test",
+ "Should return the correct distribution Id"
+ );
+});
+
+add_task(async function test_fxViewButtonAreaType_default() {
+ is(
+ typeof (await ASRouterTargeting.Environment.fxViewButtonAreaType),
+ "string",
+ "Should return a string"
+ );
+
+ is(
+ await ASRouterTargeting.Environment.fxViewButtonAreaType,
+ "toolbar",
+ "Should return name of container if button hasn't been removed"
+ );
+});
+
+add_task(async function test_fxViewButtonAreaType_removed() {
+ CustomizableUI.removeWidgetFromArea("firefox-view-button");
+
+ is(
+ await ASRouterTargeting.Environment.fxViewButtonAreaType,
+ null,
+ "Should return null if button has been removed"
+ );
+ CustomizableUI.reset();
+});
+
+add_task(async function test_creditCardsSaved() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.creditCards.supported", "on"],
+ ["extensions.formautofill.creditCards.enabled", true],
+ ],
+ });
+
+ is(
+ await ASRouterTargeting.Environment.creditCardsSaved,
+ 0,
+ "Should return 0 when no credit cards are saved"
+ );
+
+ let creditcard = {
+ "cc-name": "Test User",
+ "cc-number": "5038146897157463",
+ "cc-exp-month": "11",
+ "cc-exp-year": "20",
+ };
+
+ // Intermittently fails on macOS, likely related to Bug 1714221. So, mock the
+ // autofill actor.
+ if (AppConstants.platform === "macosx") {
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => sandbox.restore());
+ let stub = sandbox
+ .stub(
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor(
+ "FormAutofill"
+ ),
+ "receiveMessage"
+ )
+ .withArgs(
+ sandbox.match({
+ name: "FormAutofill:GetRecords",
+ data: { collectionName: "creditCards" },
+ })
+ )
+ .resolves([creditcard])
+ .callThrough();
+
+ is(
+ await ASRouterTargeting.Environment.creditCardsSaved,
+ 1,
+ "Should return 1 when 1 credit card is saved"
+ );
+ ok(
+ stub.calledWithMatch({ name: "FormAutofill:GetRecords" }),
+ "Targeting called FormAutofill:GetRecords"
+ );
+
+ sandbox.restore();
+ } else {
+ let observePromise = TestUtils.topicObserved(
+ "formautofill-storage-changed"
+ );
+ await sendFormAutofillMessage("FormAutofill:SaveCreditCard", {
+ creditcard,
+ });
+ await observePromise;
+
+ is(
+ await ASRouterTargeting.Environment.creditCardsSaved,
+ 1,
+ "Should return 1 when 1 credit card is saved"
+ );
+ await removeAutofillRecords();
+ }
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_addressesSaved() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.addresses.supported", "on"],
+ ["extensions.formautofill.addresses.enabled", true],
+ ],
+ });
+
+ is(
+ await ASRouterTargeting.Environment.addressesSaved,
+ 0,
+ "Should return 0 when no addresses are saved"
+ );
+
+ let observePromise = TestUtils.topicObserved("formautofill-storage-changed");
+ await sendFormAutofillMessage("FormAutofill:SaveAddress", {
+ address: {
+ "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: "+16172535702",
+ email: "timbl@w3.org",
+ },
+ });
+ await observePromise;
+
+ is(
+ await ASRouterTargeting.Environment.addressesSaved,
+ 1,
+ "Should return 1 when 1 address is saved"
+ );
+
+ await removeAutofillRecords();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_migrationInteractions() {
+ await pushPrefs(
+ ["browser.migrate.interactions.bookmarks", false],
+ ["browser.migrate.interactions.history", false],
+ ["browser.migrate.interactions.passwords", false]
+ );
+
+ ok(!(await ASRouterTargeting.Environment.hasMigratedBookmarks));
+ ok(!(await ASRouterTargeting.Environment.hasMigratedHistory));
+ ok(!(await ASRouterTargeting.Environment.hasMigratedPasswords));
+
+ await pushPrefs(
+ ["browser.migrate.interactions.bookmarks", true],
+ ["browser.migrate.interactions.history", false],
+ ["browser.migrate.interactions.passwords", false]
+ );
+
+ ok(await ASRouterTargeting.Environment.hasMigratedBookmarks);
+ ok(!(await ASRouterTargeting.Environment.hasMigratedHistory));
+ ok(!(await ASRouterTargeting.Environment.hasMigratedPasswords));
+
+ await pushPrefs(
+ ["browser.migrate.interactions.bookmarks", true],
+ ["browser.migrate.interactions.history", true],
+ ["browser.migrate.interactions.passwords", false]
+ );
+
+ ok(await ASRouterTargeting.Environment.hasMigratedBookmarks);
+ ok(await ASRouterTargeting.Environment.hasMigratedHistory);
+ ok(!(await ASRouterTargeting.Environment.hasMigratedPasswords));
+
+ await pushPrefs(
+ ["browser.migrate.interactions.bookmarks", true],
+ ["browser.migrate.interactions.history", true],
+ ["browser.migrate.interactions.passwords", true]
+ );
+
+ ok(await ASRouterTargeting.Environment.hasMigratedBookmarks);
+ ok(await ASRouterTargeting.Environment.hasMigratedHistory);
+ ok(await ASRouterTargeting.Environment.hasMigratedPasswords);
+});
+
+add_task(async function check_useEmbeddedMigrationWizard() {
+ await pushPrefs([
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "default",
+ ]);
+
+ ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard));
+
+ await pushPrefs([
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "autoclose",
+ ]);
+
+ ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard));
+
+ await pushPrefs([
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "embedded",
+ ]);
+
+ ok(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard);
+
+ await pushPrefs([
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "standalone",
+ ]);
+
+ ok(!(await ASRouterTargeting.Environment.useEmbeddedMigrationWizard));
+});
+
+add_task(async function check_isRTAMO() {
+ is(
+ typeof ASRouterTargeting.Environment.isRTAMO,
+ "boolean",
+ "Should return a boolean"
+ );
+
+ const TEST_CASES = [
+ {
+ title: "no attribution data",
+ attributionData: {},
+ expected: false,
+ },
+ {
+ title: "null attribution data",
+ attributionData: null,
+ expected: false,
+ },
+ {
+ title: "no content",
+ attributionData: {
+ source: "addons.mozilla.org",
+ },
+ expected: false,
+ },
+ {
+ title: "empty content",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: "",
+ },
+ expected: false,
+ },
+ {
+ title: "null content",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: null,
+ },
+ expected: false,
+ },
+ {
+ title: "empty source",
+ attributionData: {
+ source: "",
+ },
+ expected: false,
+ },
+ {
+ title: "null source",
+ attributionData: {
+ source: null,
+ },
+ expected: false,
+ },
+ {
+ title: "valid attribution data for RTAMO with content not encoded",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: "rta:<encoded-addon-id>",
+ },
+ expected: true,
+ },
+ {
+ title: "valid attribution data for RTAMO with content encoded once",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: "rta%3A<encoded-addon-id>",
+ },
+ expected: true,
+ },
+ {
+ title: "valid attribution data for RTAMO with content encoded twice",
+ attributionData: {
+ source: "addons.mozilla.org",
+ content: "rta%253A<encoded-addon-id>",
+ },
+ expected: true,
+ },
+ {
+ title: "invalid source",
+ attributionData: {
+ source: "www.mozilla.org",
+ content: "rta%3A<encoded-addon-id>",
+ },
+ expected: false,
+ },
+ ];
+
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => {
+ sandbox.restore();
+ });
+
+ const stub = sandbox.stub(AttributionCode, "getCachedAttributionData");
+
+ for (const { title, attributionData, expected } of TEST_CASES) {
+ stub.returns(attributionData);
+
+ is(
+ ASRouterTargeting.Environment.isRTAMO,
+ expected,
+ `${title} - Expected isRTAMO to have the expected value`
+ );
+ }
+
+ sandbox.restore();
+});
+
+add_task(async function check_isDeviceMigration() {
+ is(
+ typeof ASRouterTargeting.Environment.isDeviceMigration,
+ "boolean",
+ "Should return a boolean"
+ );
+
+ const TEST_CASES = [
+ {
+ title: "no attribution data",
+ attributionData: {},
+ expected: false,
+ },
+ {
+ title: "null attribution data",
+ attributionData: null,
+ expected: false,
+ },
+ {
+ title: "no campaign",
+ attributionData: {
+ source: "support.mozilla.org",
+ },
+ expected: false,
+ },
+ {
+ title: "empty campaign",
+ attributionData: {
+ source: "support.mozilla.org",
+ campaign: "",
+ },
+ expected: false,
+ },
+ {
+ title: "null campaign",
+ attributionData: {
+ source: "addons.mozilla.org",
+ campaign: null,
+ },
+ expected: false,
+ },
+ {
+ title: "empty source",
+ attributionData: {
+ source: "",
+ },
+ expected: false,
+ },
+ {
+ title: "null source",
+ attributionData: {
+ source: null,
+ },
+ expected: false,
+ },
+ {
+ title: "other source",
+ attributionData: {
+ source: "www.mozilla.org",
+ campaign: "migration",
+ },
+ expected: true,
+ },
+ {
+ title: "valid attribution data for isDeviceMigration",
+ attributionData: {
+ source: "support.mozilla.org",
+ campaign: "migration",
+ },
+ expected: true,
+ },
+ ];
+
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(async () => {
+ sandbox.restore();
+ });
+
+ const stub = sandbox.stub(AttributionCode, "getCachedAttributionData");
+
+ for (const { title, attributionData, expected } of TEST_CASES) {
+ stub.returns(attributionData);
+
+ is(
+ ASRouterTargeting.Environment.isDeviceMigration,
+ expected,
+ `${title} - Expected isDeviceMigration to have the expected value`
+ );
+ }
+
+ sandbox.restore();
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_toast_notification.js b/browser/components/newtab/test/browser/browser_asrouter_toast_notification.js
new file mode 100644
index 0000000000..18f8594dbe
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_toast_notification.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// At the time of writing, toast notifications (including XUL notifications)
+// don't support action buttons, so there's little to be tested here beyond
+// display.
+
+"use strict";
+
+const { ToastNotification } = ChromeUtils.import(
+ "resource://activity-stream/lib/ToastNotification.jsm"
+);
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/PanelTestProvider.sys.mjs"
+);
+
+function getMessage(id) {
+ return PanelTestProvider.getMessages().then(msgs =>
+ msgs.find(m => m.id === id)
+ );
+}
+
+// Ensure we don't fall back to a real implementation.
+const showAlertStub = sinon.stub();
+const AlertsServiceStub = sinon.stub(ToastNotification, "AlertsService").value({
+ showAlert: showAlertStub,
+});
+
+registerCleanupFunction(() => {
+ AlertsServiceStub.restore();
+});
+
+// Test that toast notifications do, in fact, invoke the AlertsService. These
+// tests don't *need* to be `browser` tests, but we may eventually be able to
+// interact with the XUL notification elements, which would require `browser`
+// tests, so we follow suit with the equivalent `Spotlight`, etc, tests and use
+// the `browser` framework.
+add_task(async function test_showAlert() {
+ const l10n = new Localization([
+ "branding/brand.ftl",
+ "browser/newtab/asrouter.ftl",
+ ]);
+ let expectedTitle = await l10n.formatValue(
+ "cfr-doorhanger-bookmark-fxa-header"
+ );
+
+ showAlertStub.reset();
+
+ let dispatchStub = sinon.stub();
+
+ let message = await getMessage("TEST_TOAST_NOTIFICATION1");
+ await ToastNotification.showToastNotification(message, dispatchStub);
+
+ // Test display.
+ Assert.equal(
+ showAlertStub.callCount,
+ 1,
+ "AlertsService.showAlert is invoked"
+ );
+
+ let [alert] = showAlertStub.firstCall.args;
+ Assert.equal(alert.title, expectedTitle, "Should match");
+ Assert.equal(alert.text, "Body", "Should match");
+ Assert.equal(alert.name, "test_toast_notification", "Should match");
+});
+
+// Test that the `title` of each `action` of a toast notification is localized.
+add_task(async function test_actionLocalization() {
+ const l10n = new Localization([
+ "branding/brand.ftl",
+ "browser/newtab/asrouter.ftl",
+ ]);
+ let expectedTitle = await l10n.formatValue(
+ "mr2022-background-update-toast-title"
+ );
+ let expectedText = await l10n.formatValue(
+ "mr2022-background-update-toast-text"
+ );
+ let expectedPrimary = await l10n.formatValue(
+ "mr2022-background-update-toast-primary-button-label"
+ );
+ let expectedSecondary = await l10n.formatValue(
+ "mr2022-background-update-toast-secondary-button-label"
+ );
+
+ showAlertStub.reset();
+
+ let dispatchStub = sinon.stub();
+
+ let message = await getMessage("MR2022_BACKGROUND_UPDATE_TOAST_NOTIFICATION");
+ await ToastNotification.showToastNotification(message, dispatchStub);
+
+ // Test display.
+ Assert.equal(
+ showAlertStub.callCount,
+ 1,
+ "AlertsService.showAlert is invoked"
+ );
+
+ let [alert] = showAlertStub.firstCall.args;
+ Assert.equal(alert.title, expectedTitle, "Should match title");
+ Assert.equal(alert.text, expectedText, "Should match text");
+ Assert.equal(alert.name, "mr2022_background_update", "Should match");
+ Assert.equal(alert.actions[0].title, expectedPrimary, "Should match primary");
+ Assert.equal(
+ alert.actions[1].title,
+ expectedSecondary,
+ "Should match secondary"
+ );
+});
+
+// Test that toast notifications report sensible telemetry.
+add_task(async function test_telemetry() {
+ let dispatchStub = sinon.stub();
+
+ let message = await getMessage("TEST_TOAST_NOTIFICATION1");
+ await ToastNotification.showToastNotification(message, dispatchStub);
+
+ Assert.equal(
+ dispatchStub.callCount,
+ 2,
+ "1 IMPRESSION and 1 TOAST_NOTIFICATION_TELEMETRY"
+ );
+ Assert.equal(
+ dispatchStub.firstCall.args[0].type,
+ "TOAST_NOTIFICATION_TELEMETRY",
+ "Should match"
+ );
+ Assert.equal(
+ dispatchStub.firstCall.args[0].data.event,
+ "IMPRESSION",
+ "Should match"
+ );
+ Assert.equal(
+ dispatchStub.secondCall.args[0].type,
+ "IMPRESSION",
+ "Should match"
+ );
+});
diff --git a/browser/components/newtab/test/browser/browser_asrouter_toolbarbadge.js b/browser/components/newtab/test/browser/browser_asrouter_toolbarbadge.js
new file mode 100644
index 0000000000..f0089a2364
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_toolbarbadge.js
@@ -0,0 +1,149 @@
+const { OnboardingMessageProvider } = ChromeUtils.import(
+ "resource://activity-stream/lib/OnboardingMessageProvider.jsm"
+);
+const { ToolbarBadgeHub } = ChromeUtils.import(
+ "resource://activity-stream/lib/ToolbarBadgeHub.jsm"
+);
+
+add_task(async function test_setup() {
+ // Cleanup pref value because we click the fxa accounts button.
+ // This is not required during tests because we "force show" the message
+ // by sending it directly to the Hub bypassing targeting.
+ registerCleanupFunction(() => {
+ // Clicking on the Firefox Accounts button while in the signed out
+ // state opens a new tab for signing in.
+ // We'll clean those up here for now.
+ gBrowser.removeAllTabsBut(gBrowser.tabs[0]);
+ // Stop the load in the last tab that remains.
+ gBrowser.stop();
+ Services.prefs.clearUserPref("identity.fxaccounts.toolbar.accessed");
+ });
+});
+
+add_task(async function test_fxa_badge_shown_nodelay() {
+ const [msg] = (await OnboardingMessageProvider.getMessages()).filter(
+ ({ id }) => id === "FXA_ACCOUNTS_BADGE"
+ );
+
+ Assert.ok(msg, "FxA test message exists");
+
+ // Ensure we badge immediately
+ msg.content.delay = undefined;
+
+ let browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+ // Click the button and clear the badge that occurs normally at startup
+ let fxaButton = browserWindow.document.getElementById(msg.content.target);
+ fxaButton.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Initially element is not badged"
+ );
+
+ ToolbarBadgeHub.registerBadgeNotificationListener(msg);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Wait for element to be badged"
+ );
+
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Wait for element to be badged"
+ );
+
+ await BrowserTestUtils.closeWindow(newWin);
+ browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+ // Click the button and clear the badge
+ fxaButton = document.getElementById(msg.content.target);
+ fxaButton.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Button should no longer be badged"
+ );
+});
+
+add_task(async function test_fxa_badge_shown_withdelay() {
+ const [msg] = (await OnboardingMessageProvider.getMessages()).filter(
+ ({ id }) => id === "FXA_ACCOUNTS_BADGE"
+ );
+
+ Assert.ok(msg, "FxA test message exists");
+
+ // Enough to trigger the setTimeout badging
+ msg.content.delay = 1;
+
+ let browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+ // Click the button and clear the badge that occurs normally at startup
+ let fxaButton = browserWindow.document.getElementById(msg.content.target);
+ fxaButton.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Initially element is not badged"
+ );
+
+ ToolbarBadgeHub.registerBadgeNotificationListener(msg);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Wait for element to be badged"
+ );
+
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Wait for element to be badged"
+ );
+
+ await BrowserTestUtils.closeWindow(newWin);
+ browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+ // Click the button and clear the badge
+ fxaButton = document.getElementById(msg.content.target);
+ fxaButton.click();
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ !browserWindow.document
+ .getElementById(msg.content.target)
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout"),
+ "Button should no longer be badged"
+ );
+});
diff --git a/browser/components/newtab/test/browser/browser_context_menu_item.js b/browser/components/newtab/test/browser/browser_context_menu_item.js
new file mode 100644
index 0000000000..6a4883ab93
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_context_menu_item.js
@@ -0,0 +1,18 @@
+"use strict";
+
+// Test that we do not set icons in individual tile and card context menus on
+// newtab page.
+test_newtab({
+ test: async function test_contextMenuIcons() {
+ const siteSelector = ".top-sites-list:not(.search-shortcut, .placeholder)";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(siteSelector),
+ "Topsites have loaded"
+ );
+ const contextMenuItems = await content.openContextMenuAndGetOptions(
+ siteSelector
+ );
+ let icon = contextMenuItems[0].querySelector(".icon");
+ ok(!icon, "icon was not rendered");
+ },
+});
diff --git a/browser/components/newtab/test/browser/browser_customize_menu_content.js b/browser/components/newtab/test/browser/browser_customize_menu_content.js
new file mode 100644
index 0000000000..861814793a
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_customize_menu_content.js
@@ -0,0 +1,222 @@
+"use strict";
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs(
+ ["browser.newtabpage.activity-stream.feeds.topsites", false],
+ ["browser.newtabpage.activity-stream.feeds.section.topstories", false],
+ ["browser.newtabpage.activity-stream.feeds.section.highlights", false]
+ );
+ },
+ test: async function test_render_customizeMenu() {
+ const TOPSITES_PREF = "browser.newtabpage.activity-stream.feeds.topsites";
+ const HIGHLIGHTS_PREF =
+ "browser.newtabpage.activity-stream.feeds.section.highlights";
+ const TOPSTORIES_PREF =
+ "browser.newtabpage.activity-stream.feeds.section.topstories";
+
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".personalize-button"),
+ "Wait for prefs button to load on the newtab page"
+ );
+
+ let customizeButton = content.document.querySelector(".personalize-button");
+ customizeButton.click();
+
+ let defaultPos = "matrix(1, 0, 0, 1, 0, 0)";
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.getComputedStyle(
+ content.document.querySelector(".customize-menu")
+ ).transform === defaultPos,
+ "Customize Menu should be visible on screen"
+ );
+
+ // Test that clicking the shortcuts toggle will make the section appear on the newtab page.
+ let shortcutsSwitch = content.document.querySelector(
+ "#shortcuts-section .switch"
+ );
+ let shortcutsSection = content.document.querySelector(
+ "section[data-section-id='topsites']"
+ );
+ Assert.ok(
+ !Services.prefs.getBoolPref(TOPSITES_PREF),
+ "Topsites are turned off"
+ );
+ Assert.ok(!shortcutsSection, "Shortcuts section is not rendered");
+
+ let prefPromise = ContentTaskUtils.waitForCondition(
+ () => Services.prefs.getBoolPref(TOPSITES_PREF),
+ "TopSites pref is turned on"
+ );
+ shortcutsSwitch.click();
+ await prefPromise;
+
+ Assert.ok(
+ content.document.querySelector("section[data-section-id='topsites']"),
+ "Shortcuts section is rendered"
+ );
+
+ // Test that clicking the pocket toggle will make the pocket section appear on the newtab page
+ let pocketSwitch = content.document.querySelector(
+ "#pocket-section .switch"
+ );
+ Assert.ok(
+ !Services.prefs.getBoolPref(TOPSTORIES_PREF),
+ "Pocket pref is turned off"
+ );
+ Assert.ok(
+ !content.document.querySelector("section[data-section-id='topstories']"),
+ "Pocket section is not rendered"
+ );
+
+ prefPromise = ContentTaskUtils.waitForCondition(
+ () => Services.prefs.getBoolPref(TOPSTORIES_PREF),
+ "Pocket pref is turned on"
+ );
+ pocketSwitch.click();
+ await prefPromise;
+
+ Assert.ok(
+ content.document.querySelector("section[data-section-id='topstories']"),
+ "Pocket section is rendered"
+ );
+
+ // Test that clicking the recent activity toggle will make the recent activity section appear on the newtab page
+ let highlightsSwitch = content.document.querySelector(
+ "#recent-section .switch"
+ );
+ Assert.ok(
+ !Services.prefs.getBoolPref(HIGHLIGHTS_PREF),
+ "Highlights pref is turned off"
+ );
+ Assert.ok(
+ !content.document.querySelector("section[data-section-id='highlights']"),
+ "Highlights section is not rendered"
+ );
+
+ prefPromise = ContentTaskUtils.waitForCondition(
+ () => Services.prefs.getBoolPref(HIGHLIGHTS_PREF),
+ "Highlights pref is turned on"
+ );
+ highlightsSwitch.click();
+ await prefPromise;
+
+ Assert.ok(
+ content.document.querySelector("section[data-section-id='highlights']"),
+ "Highlights section is rendered"
+ );
+ },
+ async after() {
+ Services.prefs.clearUserPref(
+ "browser.newtabpage.activity-stream.feeds.topsites"
+ );
+ Services.prefs.clearUserPref(
+ "browser.newtabpage.activity-stream.feeds.section.topstories"
+ );
+ Services.prefs.clearUserPref(
+ "browser.newtabpage.activity-stream.feeds.section.highlights"
+ );
+ },
+});
+
+test_newtab({
+ test: async function test_open_close_customizeMenu() {
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".personalize-button"),
+ "Wait for prefs button to load on the newtab page"
+ );
+
+ let customizeButton = content.document.querySelector(".personalize-button");
+ customizeButton.click();
+
+ let defaultPos = "matrix(1, 0, 0, 1, 0, 0)";
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.getComputedStyle(
+ content.document.querySelector(".customize-menu")
+ ).transform === defaultPos,
+ "Customize Menu should be visible on screen"
+ );
+
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.activeElement.classList.contains("close-button"),
+ "Close button should be focused when menu becomes visible"
+ );
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.getComputedStyle(
+ content.document.querySelector(".personalize-button")
+ ).visibility === "hidden",
+ "Personalize button should become hidden"
+ );
+
+ // Test close button.
+ let closeButton = content.document.querySelector(".close-button");
+ closeButton.click();
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.getComputedStyle(
+ content.document.querySelector(".customize-menu")
+ ).transform !== defaultPos,
+ "Customize Menu should not be visible anymore"
+ );
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.activeElement.classList.contains("personalize-button"),
+ "Personalize button should be focused when menu closes"
+ );
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.getComputedStyle(
+ content.document.querySelector(".personalize-button")
+ ).visibility === "visible",
+ "Personalize button should become visible"
+ );
+
+ // Reopen the customize menu
+ customizeButton.click();
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.getComputedStyle(
+ content.document.querySelector(".customize-menu")
+ ).transform === defaultPos,
+ "Customize Menu should be visible on screen now"
+ );
+
+ // Test closing with esc key.
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, content);
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.getComputedStyle(
+ content.document.querySelector(".customize-menu")
+ ).transform !== defaultPos,
+ "Customize Menu should not be visible anymore"
+ );
+
+ // Reopen the customize menu
+ customizeButton.click();
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.getComputedStyle(
+ content.document.querySelector(".customize-menu")
+ ).transform === defaultPos,
+ "Customize Menu should be visible on screen now"
+ );
+
+ // Test closing with external click.
+ let outerWrapper = content.document.querySelector(".outer-wrapper");
+ outerWrapper.click();
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.getComputedStyle(
+ content.document.querySelector(".customize-menu")
+ ).transform !== defaultPos,
+ "Customize Menu should not be visible anymore"
+ );
+ },
+});
diff --git a/browser/components/newtab/test/browser/browser_customize_menu_render.js b/browser/components/newtab/test/browser/browser_customize_menu_render.js
new file mode 100644
index 0000000000..0ed761c181
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_customize_menu_render.js
@@ -0,0 +1,27 @@
+"use strict";
+
+// Test that the customization menu is rendered.
+test_newtab({
+ test: async function test_render_customizeMenu() {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".personalize-button"),
+ "Wait for personalize button to load on the newtab page"
+ );
+
+ let defaultPos = "matrix(1, 0, 0, 1, 0, 0)";
+ ok(
+ content.getComputedStyle(
+ content.document.querySelector(".customize-menu")
+ ).transform !== defaultPos,
+ "Customize Menu should be rendered, but not visible"
+ );
+
+ let customizeButton = content.document.querySelector(".personalize-button");
+ customizeButton.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".customize-menu"),
+ "Customize Menu should be rendered now"
+ );
+ },
+});
diff --git a/browser/components/newtab/test/browser/browser_discovery_card.js b/browser/components/newtab/test/browser/browser_discovery_card.js
new file mode 100644
index 0000000000..c1d9ec6b4c
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_discovery_card.js
@@ -0,0 +1,44 @@
+// If this fails it could be because of schema changes.
+// `ds_layout.json` defines the newtab page format
+// `topstories.json` defines the stories shown
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.discoverystream.config",
+ JSON.stringify({
+ api_key_pref: "extensions.pocket.oAuthConsumerKey",
+ collapsible: true,
+ enabled: true,
+ show_spocs: false,
+ hardcoded_layout: false,
+ personalized: true,
+ layout_endpoint:
+ "https://example.com/browser/browser/components/newtab/test/browser/ds_layout.json",
+ }),
+ ]);
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.discoverystream.endpoints",
+ "https://example.com",
+ ]);
+ },
+ test: async function test_card_render() {
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelectorAll(
+ "[data-section-id='topstories'] .ds-card-link"
+ ).length
+ );
+ let found = content.document.querySelectorAll(
+ "[data-section-id='topstories'] .ds-card-link"
+ ).length;
+ is(found, 1, "there should be 1 topstory card");
+ let cardHostname = content.document.querySelector(
+ "[data-section-id='topstories'] .source"
+ ).innerText;
+ is(
+ cardHostname,
+ "bbc.com",
+ `Card hostname is ${cardHostname} instead of bbc.com`
+ );
+ },
+});
diff --git a/browser/components/newtab/test/browser/browser_discovery_render.js b/browser/components/newtab/test/browser/browser_discovery_render.js
new file mode 100644
index 0000000000..86b0410698
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_discovery_render.js
@@ -0,0 +1,32 @@
+"use strict";
+
+async function before({ pushPrefs }) {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.discoverystream.config",
+ JSON.stringify({
+ collapsible: true,
+ enabled: true,
+ hardcoded_layout: true,
+ }),
+ ]);
+}
+
+test_newtab({
+ before,
+ test: async function test_render_hardcoded_topsites() {
+ const topSites = await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector(".ds-top-sites")
+ );
+ ok(topSites, "Got the discovery stream top sites section");
+ },
+});
+
+test_newtab({
+ before,
+ test: async function test_render_hardcoded_learnmore() {
+ const learnMoreLink = await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector(".ds-layout .learn-more-link > a")
+ );
+ ok(learnMoreLink, "Got the discovery stream learn more link");
+ },
+});
diff --git a/browser/components/newtab/test/browser/browser_discovery_styles.js b/browser/components/newtab/test/browser/browser_discovery_styles.js
new file mode 100644
index 0000000000..03f830d2ee
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_discovery_styles.js
@@ -0,0 +1,171 @@
+"use strict";
+
+function fakePref(layout) {
+ return [
+ "browser.newtabpage.activity-stream.discoverystream.config",
+ JSON.stringify({
+ enabled: true,
+ layout_endpoint: `data:,${encodeURIComponent(JSON.stringify(layout))}`,
+ }),
+ ];
+}
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs(
+ fakePref({
+ layout: [
+ {
+ width: 12,
+ components: [
+ {
+ type: "TopSites",
+ },
+ {
+ type: "HorizontalRule",
+ styles: {
+ hr: "border-width: 3.14159mm",
+ },
+ },
+ ],
+ },
+ ],
+ })
+ );
+ },
+ test: async function test_hr_override() {
+ const hr = await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector("hr")
+ );
+ ok(
+ content.getComputedStyle(hr).borderTopWidth.match(/11.?\d*px/),
+ "applied and normalized hr component width override"
+ );
+ },
+});
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs(
+ fakePref({
+ layout: [
+ {
+ width: 12,
+ components: [
+ {
+ type: "TopSites",
+ },
+ {
+ type: "HorizontalRule",
+ styles: {
+ "*": "color: #f00",
+ "": "font-size: 1.2345cm",
+ hr: "font-weight: 12345",
+ },
+ },
+ ],
+ },
+ ],
+ })
+ );
+ },
+ test: async function test_multiple_overrides() {
+ const hr = await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector("hr")
+ );
+ const styles = content.getComputedStyle(hr);
+ is(styles.color, "rgb(255, 0, 0)", "applied and normalized color");
+ is(styles.fontSize, "46.6583px", "applied and normalized font size");
+ is(styles.fontWeight, "400", "applied and normalized font weight");
+ },
+});
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs(
+ fakePref({
+ layout: [
+ {
+ width: 12,
+ components: [
+ {
+ type: "HorizontalRule",
+ styles: {
+ // NB: Use display: none to avoid network requests to unfiltered urls
+ hr: `display: none;
+ background-image: url(https://example.com/background);
+ content: url(chrome://browser/content);
+ cursor: url( resource://activity-stream/cursor ), auto;
+ list-style-image: url('https://img-getpocket.cdn.mozilla.net/list');`,
+ },
+ },
+ ],
+ },
+ ],
+ })
+ );
+ },
+ test: async function test_url_filtering() {
+ const hr = await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector("hr")
+ );
+ const styles = content.getComputedStyle(hr);
+ is(
+ styles.backgroundImage,
+ "none",
+ "filtered out invalid background image url"
+ );
+ is(
+ styles.content,
+ `url("chrome://browser/content/browser.xul")`,
+ "applied, normalized and allowed content url"
+ );
+ is(
+ styles.cursor,
+ `url("resource://activity-stream/cursor"), auto`,
+ "applied, normalized and allowed cursor url"
+ );
+ is(
+ styles.listStyleImage,
+ `url("https://img-getpocket.cdn.mozilla.net/list")`,
+ "applied, normalized and allowed list style image url"
+ );
+ },
+});
+
+test_newtab({
+ async before({ pushPrefs }) {
+ await pushPrefs(
+ fakePref({
+ layout: [
+ {
+ width: 12,
+ components: [
+ {
+ type: "HorizontalRule",
+ styles: {
+ "@media (min-width: 0)":
+ "content: url(chrome://browser/content)",
+ "@media (min-width: 0) *":
+ "content: url(chrome://browser/content)",
+ "@media (min-width: 0) { * }":
+ "content: url(chrome://browser/content)",
+ },
+ },
+ ],
+ },
+ ],
+ })
+ );
+ },
+ test: async function test_atrule_filtering() {
+ const hr = await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector("hr")
+ );
+ is(
+ content.getComputedStyle(hr).content,
+ "normal",
+ "filtered out attempted @media query"
+ );
+ },
+});
diff --git a/browser/components/newtab/test/browser/browser_enabled_newtabpage.js b/browser/components/newtab/test/browser/browser_enabled_newtabpage.js
new file mode 100644
index 0000000000..8762160cb1
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_enabled_newtabpage.js
@@ -0,0 +1,33 @@
+function getSpec(uri) {
+ const { spec } = NetUtil.newChannel({
+ loadUsingSystemPrincipal: true,
+ uri,
+ }).URI;
+
+ info(`got ${spec} for ${uri}`);
+ return spec;
+}
+
+add_task(async function test_newtab_enabled() {
+ ok(
+ !getSpec("about:newtab").endsWith("/blanktab.html"),
+ "did not get blank for default about:newtab"
+ );
+ ok(
+ !getSpec("about:home").endsWith("/blanktab.html"),
+ "did not get blank for default about:home"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.enabled", false]],
+ });
+
+ ok(
+ getSpec("about:newtab").endsWith("/blanktab.html"),
+ "got special blank page when newtab is not enabled"
+ );
+ ok(
+ !getSpec("about:home").endsWith("/blanktab.html"),
+ "got special blank page for about:home"
+ );
+});
diff --git a/browser/components/newtab/test/browser/browser_feature_callout_in_chrome.js b/browser/components/newtab/test/browser/browser_feature_callout_in_chrome.js
new file mode 100644
index 0000000000..5eff75e31e
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_feature_callout_in_chrome.js
@@ -0,0 +1,487 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+const calloutId = "multi-stage-message-root";
+const calloutSelector = `#${calloutId}.featureCallout`;
+const primaryButtonSelector = `#${calloutId} .primary`;
+const PDF_TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/file_pdf.PDF";
+
+const waitForCalloutScreen = async (doc, screenId) => {
+ await BrowserTestUtils.waitForCondition(() => {
+ return doc.querySelector(`${calloutSelector}:not(.hidden) .${screenId}`);
+ });
+};
+
+const waitForRemoved = async doc => {
+ await BrowserTestUtils.waitForCondition(() => {
+ return !doc.querySelector(calloutSelector);
+ });
+};
+
+async function openURLInWindow(window, url) {
+ const { selectedBrowser } = window.gBrowser;
+ BrowserTestUtils.loadURIString(selectedBrowser, url);
+ await BrowserTestUtils.browserLoaded(selectedBrowser, false, url);
+}
+
+async function openURLInNewTab(window, url) {
+ return BrowserTestUtils.openNewForegroundTab(window.gBrowser, url);
+}
+
+const pdfMatch = sinon.match(val => {
+ return val?.id === "featureCalloutCheck" && val?.context?.source === "chrome";
+});
+
+const validateCalloutCustomPosition = (element, positionOverride, doc) => {
+ const browserBox = doc.querySelector("hbox#browser");
+ for (let position in positionOverride) {
+ if (Object.prototype.hasOwnProperty.call(positionOverride, position)) {
+ // The substring here is to remove the `px` at the end of our position override strings
+ const relativePos = positionOverride[position].substring(
+ 0,
+ positionOverride[position].length - 2
+ );
+ const elPos = element.getBoundingClientRect()[position];
+ const browserPos = browserBox.getBoundingClientRect()[position];
+
+ if (position in ["top", "left"]) {
+ if (elPos !== browserPos + relativePos) {
+ return false;
+ }
+ } else if (position in ["right", "bottom"]) {
+ if (elPos !== browserPos - relativePos) {
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+};
+
+const validateCalloutRTLPosition = (element, positionOverride) => {
+ for (let position in positionOverride) {
+ if (Object.prototype.hasOwnProperty.call(positionOverride, position)) {
+ const pixelPosition = positionOverride[position];
+ if (position === "left") {
+ const actualLeft = Number(
+ pixelPosition.substring(0, pixelPosition.length - 2)
+ );
+ if (element.getBoundingClientRect().right !== actualLeft) {
+ return false;
+ }
+ } else if (position === "right") {
+ const expectedLeft = Number(
+ pixelPosition.substring(0, pixelPosition.length - 2)
+ );
+ if (element.getBoundingClientRect().left !== expectedLeft) {
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+};
+
+const testMessage = {
+ message: {
+ id: "TEST_MESSAGE",
+ template: "feature_callout",
+ content: {
+ id: "TEST_MESSAGE",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ screens: [
+ {
+ id: "TEST_MESSAGE_1",
+ parent_selector: "#urlbar-container",
+ content: {
+ position: "callout",
+ arrow_position: "top-end",
+ title: {
+ raw: "Test title",
+ },
+ subtitle: {
+ raw: "Test subtitle",
+ },
+ primary_button: {
+ label: {
+ raw: "Done",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ priority: 1,
+ targeting: "true",
+ trigger: { id: "featureCalloutCheck" },
+ },
+};
+
+const testMessageCalloutSelector = testMessage.message.content.screens[0].id;
+
+add_setup(async function () {
+ requestLongerTimeout(2);
+});
+
+add_task(async function feature_callout_renders_in_browser_chrome_for_pdf() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, testMessageCalloutSelector);
+ const container = doc.querySelector(calloutSelector);
+ ok(
+ container,
+ "Feature Callout is rendered in the browser chrome with a new window when a message is available"
+ );
+
+ // click primary button to close
+ doc.querySelector(primaryButtonSelector).click();
+ await waitForRemoved(doc);
+ ok(
+ true,
+ "Feature callout removed from browser chrome after clicking button configured to navigate"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+});
+
+add_task(
+ async function feature_callout_renders_and_hides_in_chrome_when_switching_tabs() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const doc = win.document;
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ PDF_TEST_URL
+ );
+ tab1.focus();
+ await waitForCalloutScreen(doc, testMessageCalloutSelector);
+ ok(
+ doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout rendered when opening a new tab with PDF url"
+ );
+
+ const tab2 = await openURLInNewTab(win, "about:preferences");
+ tab2.focus();
+ await BrowserTestUtils.waitForCondition(() => {
+ return !doc.body.querySelector(
+ "#multi-stage-message-root.featureCallout"
+ );
+ });
+
+ ok(
+ !doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout removed when tab without PDF URL is navigated to"
+ );
+
+ const tab3 = await openURLInNewTab(win, PDF_TEST_URL);
+ tab3.focus();
+ await waitForCalloutScreen(doc, testMessageCalloutSelector);
+ ok(
+ doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout still renders when opening a new tab with PDF url after being initially rendered on another tab"
+ );
+
+ tab1.focus();
+ await waitForCalloutScreen(doc, testMessageCalloutSelector);
+ ok(
+ doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout rendered on original tab after switching tabs multiple times"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_disappears_when_navigating_to_non_pdf_url_in_same_tab() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const doc = win.document;
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ PDF_TEST_URL
+ );
+ tab1.focus();
+ await waitForCalloutScreen(doc, testMessageCalloutSelector);
+ ok(
+ doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout rendered when opening a new tab with PDF url"
+ );
+
+ BrowserTestUtils.loadURIString(win.gBrowser, "about:preferences");
+ await BrowserTestUtils.waitForLocationChange(
+ win.gBrowser,
+ "about:preferences"
+ );
+ await waitForRemoved(doc);
+
+ ok(
+ !doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout not rendered on original tab after navigating to non pdf URL"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_disappears_when_closing_foreground_pdf_tab() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const doc = win.document;
+ const tab1 = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ PDF_TEST_URL
+ );
+ tab1.focus();
+ await waitForCalloutScreen(doc, testMessageCalloutSelector);
+ ok(
+ doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout rendered when opening a new tab with PDF url"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+ await waitForRemoved(doc);
+
+ ok(
+ !doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout disappears after closing foreground tab"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_does_not_appear_when_opening_background_pdf_tab() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const doc = win.document;
+
+ const tab1 = await BrowserTestUtils.addTab(win.gBrowser, PDF_TEST_URL);
+ ok(
+ !doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout not rendered when opening a background tab with PDF url"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+
+ ok(
+ !doc.querySelector(`.${testMessageCalloutSelector}`),
+ "Feature callout still not rendered after closing background tab with PDF url"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function feature_callout_is_positioned_relative_to_browser_window() {
+ // Deep copying our test message so we can alter it without disrupting future tests
+ const pdfTestMessage = JSON.parse(JSON.stringify(testMessage));
+ const pdfTestMessageCalloutSelector =
+ pdfTestMessage.message.content.screens[0].id;
+
+ pdfTestMessage.message.content.screens[0].parent_selector = "hbox#browser";
+ pdfTestMessage.message.content.screens[0].content.callout_position_override =
+ {
+ top: "45px",
+ right: "25px",
+ };
+
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(pdfTestMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, pdfTestMessageCalloutSelector);
+
+ // Verify that callout renders in appropriate position (without infobar element)
+ const callout = doc.querySelector(`.${pdfTestMessageCalloutSelector}`);
+ ok(callout, "Callout is rendered when navigating to PDF file");
+ ok(
+ validateCalloutCustomPosition(
+ callout,
+ pdfTestMessage.message.content.screens[0].content
+ .callout_position_override,
+ doc
+ ),
+ "Callout custom position is as expected"
+ );
+
+ // Add height to the top of the browser to simulate an infobar or other element
+ const navigatorToolBox = doc.querySelector("#navigator-toolbox-background");
+ navigatorToolBox.style.height = "150px";
+ // We test in a new tab because the callout does not adjust itself
+ // when size of the navigator-toolbox-background box changes.
+ const tab = await openURLInNewTab(win, "https://example.com/some2.pdf");
+ // Verify that callout renders in appropriate position (with infobar element displayed)
+ ok(
+ validateCalloutCustomPosition(
+ callout,
+ pdfTestMessage.message.content.screens[0].content
+ .callout_position_override,
+ doc
+ ),
+ "Callout custom position is as expected while navigator toolbox height is extended"
+ );
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function custom_position_callout_is_horizontally_reversed_in_rtl_layouts() {
+ // Deep copying our test message so we can alter it without disrupting future tests
+ const pdfTestMessage = JSON.parse(JSON.stringify(testMessage));
+ const pdfTestMessageCalloutSelector =
+ pdfTestMessage.message.content.screens[0].id;
+
+ pdfTestMessage.message.content.screens[0].parent_selector = "hbox#browser";
+ pdfTestMessage.message.content.screens[0].content.callout_position_override =
+ {
+ top: "45px",
+ right: "25px",
+ };
+
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(pdfTestMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ win.document.dir = "rtl";
+ ok(
+ win.document.documentElement.getAttribute("dir") === "rtl",
+ "browser window is in RTL"
+ );
+
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, pdfTestMessageCalloutSelector);
+
+ const callout = doc.querySelector(`.${pdfTestMessageCalloutSelector}`);
+ ok(callout, "Callout is rendered when navigating to PDF file");
+ ok(
+ validateCalloutRTLPosition(
+ callout,
+ pdfTestMessage.message.content.screens[0].content
+ .callout_position_override
+ ),
+ "Callout custom position is rendered appropriately in RTL mode"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
+
+add_task(async function feature_callout_dismissed_on_escape() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, testMessageCalloutSelector);
+ const container = doc.querySelector(calloutSelector);
+ ok(
+ container,
+ "Feature Callout is rendered in the browser chrome with a new window when a message is available"
+ );
+
+ // Ensure the browser is focused
+ win.gBrowser.selectedBrowser.focus();
+
+ // Press Escape to close
+ EventUtils.synthesizeKey("KEY_Escape", {}, win);
+ await waitForRemoved(doc);
+ ok(true, "Feature callout dismissed after pressing Escape");
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+});
+
+add_task(
+ async function feature_callout_not_dismissed_on_escape_with_interactive_elm_focused() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
+ sendTriggerStub.withArgs(pdfMatch).resolves(testMessage);
+ sendTriggerStub.callThrough();
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await openURLInWindow(win, PDF_TEST_URL);
+ const doc = win.document;
+ await waitForCalloutScreen(doc, testMessageCalloutSelector);
+ const container = doc.querySelector(calloutSelector);
+ ok(
+ container,
+ "Feature Callout is rendered in the browser chrome with a new window when a message is available"
+ );
+
+ // Ensure an interactive element is focused
+ win.gURLBar.focus();
+
+ // Press Escape to close
+ EventUtils.synthesizeKey("KEY_Escape", {}, win);
+ await TestUtils.waitForTick();
+ // Wait 500ms for transition to complete
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 500));
+ ok(
+ doc.querySelector(calloutSelector),
+ "Feature callout is not dismissed after pressing Escape because an interactive element is focused"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ sandbox.restore();
+ }
+);
diff --git a/browser/components/newtab/test/browser/browser_getScreenshots.js b/browser/components/newtab/test/browser/browser_getScreenshots.js
new file mode 100644
index 0000000000..6e285c2114
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_getScreenshots.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";
+
+// a blue page
+const TEST_URL =
+ "https://example.com/browser/browser/components/newtab/test/browser/blue_page.html";
+const XHTMLNS = "http://www.w3.org/1999/xhtml";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Screenshots",
+ "resource://activity-stream/lib/Screenshots.jsm"
+);
+
+function get_pixels(stringOrObject, width, height) {
+ return new Promise(resolve => {
+ // get the pixels out of the screenshot that we just took
+ let img = document.createElementNS(XHTMLNS, "img");
+ let imgPath;
+
+ if (typeof stringOrObject === "string") {
+ Assert.ok(
+ Services.prefs.getBoolPref(
+ "browser.tabs.remote.separatePrivilegedContentProcess"
+ ),
+ "The privileged about content process should be enabled."
+ );
+ imgPath = stringOrObject;
+ Assert.ok(
+ imgPath.startsWith("moz-page-thumb://"),
+ "Thumbnails should be retrieved using moz-page-thumb://"
+ );
+ } else {
+ imgPath = URL.createObjectURL(stringOrObject.data);
+ }
+
+ img.setAttribute("src", imgPath);
+ img.addEventListener(
+ "load",
+ () => {
+ let canvas = document.createElementNS(XHTMLNS, "canvas");
+ canvas.setAttribute("width", width);
+ canvas.setAttribute("height", height);
+ let ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0, width, height);
+ const result = ctx.getImageData(0, 0, width, height).data;
+ URL.revokeObjectURL(imgPath);
+ resolve(result);
+ },
+ { once: true }
+ );
+ });
+}
+
+add_task(async function test_screenshot() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.pagethumbnails.capturing_disabled", false]],
+ });
+
+ // take a screenshot of a blue page and save it as a blob
+ const screenshotAsObject = await Screenshots.getScreenshotForURL(TEST_URL);
+ let pixels = await get_pixels(screenshotAsObject, 10, 10);
+ let rgbaCount = { r: 0, g: 0, b: 0, a: 0 };
+ while (pixels.length) {
+ // break the pixels into arrays of 4 components [red, green, blue, alpha]
+ let [r, g, b, a, ...rest] = pixels;
+ pixels = rest;
+ // count the number of each coloured pixels
+ if (r === 255) {
+ rgbaCount.r += 1;
+ }
+ if (g === 255) {
+ rgbaCount.g += 1;
+ }
+ if (b === 255) {
+ rgbaCount.b += 1;
+ }
+ if (a === 255) {
+ rgbaCount.a += 1;
+ }
+ }
+
+ // in the end, we should only have 100 blue pixels (10 x 10) with full opacity
+ Assert.equal(rgbaCount.b, 100, "Has 100 blue pixels");
+ Assert.equal(rgbaCount.a, 100, "Has full opacity");
+ Assert.equal(rgbaCount.r, 0, "Does not have any red pixels");
+ Assert.equal(rgbaCount.g, 0, "Does not have any green pixels");
+});
diff --git a/browser/components/newtab/test/browser/browser_highlights_section.js b/browser/components/newtab/test/browser/browser_highlights_section.js
new file mode 100644
index 0000000000..d73e4eb361
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_highlights_section.js
@@ -0,0 +1,96 @@
+"use strict";
+
+/**
+ * Helper for setup and cleanup of Highlights section tests.
+ * @param bookmarkCount Number of bookmark higlights to add
+ * @param test The test case
+ */
+function test_highlights(bookmarkCount, test) {
+ test_newtab({
+ async before({ tab }) {
+ if (bookmarkCount) {
+ await addHighlightsBookmarks(bookmarkCount);
+ // Wait for HighlightsFeed to update and display the items.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "[data-section-id='highlights'] .card-outer:not(.placeholder)"
+ ),
+ "No highlights cards found."
+ );
+ });
+ }
+ },
+ test,
+ async after() {
+ await clearHistoryAndBookmarks();
+ },
+ });
+}
+
+test_highlights(
+ 2, // Number of highlights cards
+ function check_highlights_cards() {
+ let found = content.document.querySelectorAll(
+ "[data-section-id='highlights'] .card-outer:not(.placeholder)"
+ ).length;
+ is(found, 2, "there should be 2 highlights cards");
+
+ found = content.document.querySelectorAll(
+ "[data-section-id='highlights'] .section-list .placeholder"
+ ).length;
+ is(found, 2, "there should be 1 row * 4 - 2 = 2 highlights placeholder");
+
+ found = content.document.querySelectorAll(
+ "[data-section-id='highlights'] .card-context-icon.icon-bookmark-added"
+ ).length;
+ is(found, 2, "there should be 2 bookmark icons");
+ }
+);
+
+test_highlights(
+ 1, // Number of highlights cards
+ function check_highlights_context_menu() {
+ const menuButton = content.document.querySelector(
+ "[data-section-id='highlights'] .card-outer .context-menu-button"
+ );
+ // Open the menu.
+ menuButton.click();
+ const found = content.document.querySelector(
+ "[data-section-id='highlights'] .card-outer .context-menu"
+ );
+ ok(found && !found.hidden, "Should find a visible context menu");
+ }
+);
+
+test_highlights(
+ 1, // Number of highlights cards
+ async function check_highlights_context_menu() {
+ const menuButton = content.document.querySelector(
+ "[data-section-id='highlights'] .card-outer .context-menu-button"
+ );
+ // Open the menu.
+ menuButton.click();
+ const contextMenu = content.document.querySelector(
+ "[data-section-id='highlights'] .card-outer .context-menu"
+ );
+ ok(
+ contextMenu && !contextMenu.hidden,
+ "Should find a visible context menu"
+ );
+
+ const removeBookmarkBtn = contextMenu.querySelector(
+ "[data-section-id='highlights'] button"
+ );
+ removeBookmarkBtn.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelectorAll(
+ "[data-section-id='highlights'] .card-outer:not(.placeholder)"
+ ),
+ "no more bookmark cards should be visible"
+ );
+ }
+);
diff --git a/browser/components/newtab/test/browser/browser_multistage_spotlight.js b/browser/components/newtab/test/browser/browser_multistage_spotlight.js
new file mode 100644
index 0000000000..bbaf64a9e3
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_multistage_spotlight.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Spotlight } = ChromeUtils.import(
+ "resource://activity-stream/lib/Spotlight.jsm"
+);
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/PanelTestProvider.sys.mjs"
+);
+const { BrowserWindowTracker } = ChromeUtils.import(
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+const { SpecialMessageActions } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs"
+);
+
+async function waitForClick(selector, win) {
+ await TestUtils.waitForCondition(() => win.document.querySelector(selector));
+ win.document.querySelector(selector).click();
+}
+
+async function showDialog(dialogOptions) {
+ Spotlight.showSpotlightDialog(
+ dialogOptions.browser,
+ dialogOptions.message,
+ dialogOptions.dispatchStub
+ );
+ const [win] = await TestUtils.topicObserved("subdialog-loaded");
+ return win;
+}
+
+add_task(async function test_specialAction() {
+ let message = (await PanelTestProvider.getMessages()).find(
+ m => m.id === "MULTISTAGE_SPOTLIGHT_MESSAGE"
+ );
+ let dispatchStub = sinon.stub();
+ let browser = BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser;
+ let specialActionStub = sinon.stub(SpecialMessageActions, "handleAction");
+
+ let win = await showDialog({ message, browser, dispatchStub });
+ await waitForClick("button.primary", win);
+ win.close();
+
+ Assert.equal(
+ specialActionStub.callCount,
+ 1,
+ "Should be called by primary action"
+ );
+ Assert.deepEqual(
+ specialActionStub.firstCall.args[0],
+ message.content.screens[0].content.primary_button.action,
+ "Should be called with button action"
+ );
+
+ specialActionStub.restore();
+});
diff --git a/browser/components/newtab/test/browser/browser_multistage_spotlight_telemetry.js b/browser/components/newtab/test/browser/browser_multistage_spotlight_telemetry.js
new file mode 100644
index 0000000000..c9c4baad83
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_multistage_spotlight_telemetry.js
@@ -0,0 +1,145 @@
+"use strict";
+
+const { Spotlight } = ChromeUtils.import(
+ "resource://activity-stream/lib/Spotlight.jsm"
+);
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/PanelTestProvider.sys.mjs"
+);
+const { BrowserWindowTracker } = ChromeUtils.import(
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+
+const { AboutWelcomeTelemetry } = ChromeUtils.import(
+ "resource://activity-stream/aboutwelcome/lib/AboutWelcomeTelemetry.jsm"
+);
+
+async function waitForClick(selector, win) {
+ await TestUtils.waitForCondition(() => win.document.querySelector(selector));
+ win.document.querySelector(selector).click();
+}
+
+function waitForDialog(callback = win => win.close()) {
+ return BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://browser/content/spotlight.html",
+ { callback, isSubDialog: true }
+ );
+}
+
+function showAndWaitForDialog(dialogOptions, callback) {
+ const promise = waitForDialog(callback);
+ Spotlight.showSpotlightDialog(
+ dialogOptions.browser,
+ dialogOptions.message,
+ dialogOptions.dispatchStub
+ );
+ return promise;
+}
+
+add_task(async function send_spotlight_as_page_in_telemetry() {
+ let message = (await PanelTestProvider.getMessages()).find(
+ m => m.id === "MULTISTAGE_SPOTLIGHT_MESSAGE"
+ );
+ let dispatchStub = sinon.stub();
+ let browser = BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser;
+ let sandbox = sinon.createSandbox();
+
+ await showAndWaitForDialog({ message, browser, dispatchStub }, async win => {
+ let stub = sandbox.stub(win, "AWSendEventTelemetry");
+ await waitForClick("button.secondary", win);
+ Assert.equal(
+ stub.lastCall.args[0].event_context.page,
+ "spotlight",
+ "The value of event context page should be set to 'spotlight' in event telemetry"
+ );
+ win.close();
+ });
+
+ sandbox.restore();
+});
+
+add_task(async function send_dismiss_event_telemetry() {
+ // Have to turn on AS telemetry for anything to be recorded.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.activity-stream.telemetry", true]],
+ });
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ });
+
+ const messageId = "MULTISTAGE_SPOTLIGHT_MESSAGE";
+ let message = (await PanelTestProvider.getMessages()).find(
+ m => m.id === messageId
+ );
+ let browser = BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser;
+ let sandbox = sinon.createSandbox();
+ sandbox
+ .stub(AboutWelcomeTelemetry.prototype, "pingCentre")
+ .value({ sendStructuredIngestionPing: () => {} });
+ let spy = sandbox.spy(AboutWelcomeTelemetry.prototype, "sendTelemetry");
+ // send without a dispatch function so that default is used
+ let pingSubmitted = false;
+ await showAndWaitForDialog({ message, browser }, async win => {
+ await waitForClick("button.dismiss-button", win);
+ await win.close();
+ // To catch the `DISMISS` and not any of the earlier events
+ // triggering "messaging-system" pings, we must position this synchronously
+ // _after_ the window closes but before `showAndWaitForDialog`'s callback
+ // completes.
+ // Too early and we'll catch an earlier event like `CLICK`.
+ // Too late and we'll not catch any event at all.
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+
+ Assert.equal(
+ messageId,
+ Glean.messagingSystem.messageId.testGetValue(),
+ "Glean was given the correct message_id"
+ );
+ Assert.equal(
+ "DISMISS",
+ Glean.messagingSystem.event.testGetValue(),
+ "Glean was given the correct event"
+ );
+ });
+ });
+
+ Assert.equal(
+ spy.lastCall.args[0].message_id,
+ messageId,
+ "A dismiss event is called with the correct message id"
+ );
+
+ Assert.equal(
+ spy.lastCall.args[0].event,
+ "DISMISS",
+ "A dismiss event is called with a top level event field with value 'DISMISS'"
+ );
+
+ Assert.ok(pingSubmitted, "The Glean ping was submitted.");
+
+ sandbox.restore();
+});
+
+add_task(
+ async function do_not_send_impression_telemetry_from_default_dispatch() {
+ // Don't send impression telemetry from the Spotlight default dispatch function
+ let message = (await PanelTestProvider.getMessages()).find(
+ m => m.id === "MULTISTAGE_SPOTLIGHT_MESSAGE"
+ );
+ let browser = BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser;
+ let sandbox = sinon.createSandbox();
+ let stub = sandbox.stub(AboutWelcomeTelemetry.prototype, "sendTelemetry");
+ // send without a dispatch function so that default is used
+ await showAndWaitForDialog({ message, browser });
+
+ Assert.equal(
+ stub.calledOn(),
+ false,
+ "No extra impression event was sent for multistage Spotlight"
+ );
+
+ sandbox.restore();
+ }
+);
diff --git a/browser/components/newtab/test/browser/browser_newtab_header.js b/browser/components/newtab/test/browser/browser_newtab_header.js
new file mode 100644
index 0000000000..adfecbe71f
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_newtab_header.js
@@ -0,0 +1,76 @@
+"use strict";
+
+// Tests that:
+// 1. Top sites header is hidden and the topsites section is not collapsed on load.
+// 2. Pocket header and section are visible and not collapsed on load.
+// 3. Recent activity section and header are visible and not collapsed on load.
+test_newtab({
+ test: async function test_render_customizeMenu() {
+ // Top sites section
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".top-sites"),
+ "Wait for the top sites section to load"
+ );
+
+ let topSitesSection = content.document.querySelector(".top-sites");
+ let titleContainer = topSitesSection.querySelector(
+ ".section-title-container"
+ );
+ ok(
+ titleContainer && titleContainer.style.visibility === "hidden",
+ "Top sites header should not be visible"
+ );
+
+ let isTopSitesCollapsed = topSitesSection.className.includes("collapsed");
+ ok(!isTopSitesCollapsed, "Top sites should not be collapsed on load");
+
+ // Pocket section
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector("section[data-section-id='topstories']"),
+ "Wait for the pocket section to load"
+ );
+
+ let pocketSection = content.document.querySelector(
+ "section[data-section-id='topstories']"
+ );
+ let isPocketSectionCollapsed =
+ pocketSection.className.includes("collapsed");
+ ok(
+ !isPocketSectionCollapsed,
+ "Pocket section should not be collapsed on load"
+ );
+
+ let pocketHeader = content.document.querySelector(
+ "section[data-section-id='topstories'] .section-title"
+ );
+ ok(
+ pocketHeader && !pocketHeader.style.visibility,
+ "Pocket header should be visible"
+ );
+
+ // Highlights (Recent activity) section.
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector("section[data-section-id='highlights']"),
+ "Wait for the highlights section to load"
+ );
+ let highlightsSection = content.document.querySelector(
+ "section[data-section-id='topstories']"
+ );
+ let isHighlightsSectionCollapsed =
+ highlightsSection.className.includes("collapsed");
+ ok(
+ !isHighlightsSectionCollapsed,
+ "Highlights section should not be collapsed on load"
+ );
+
+ let highlightsHeader = content.document.querySelector(
+ "section[data-section-id='highlights'] .section-title"
+ );
+ ok(
+ highlightsHeader && !highlightsHeader.style.visibility,
+ "Highlights header should be visible"
+ );
+ },
+});
diff --git a/browser/components/newtab/test/browser/browser_newtab_last_LinkMenu.js b/browser/components/newtab/test/browser/browser_newtab_last_LinkMenu.js
new file mode 100644
index 0000000000..2c58c9a48c
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_newtab_last_LinkMenu.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function setupPrefs() {
+ await setDefaultTopSites();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.discoverystream.config",
+ JSON.stringify({
+ api_key_pref: "extensions.pocket.oAuthConsumerKey",
+ collapsible: true,
+ enabled: true,
+ show_spocs: false,
+ hardcoded_layout: false,
+ personalized: false,
+ layout_endpoint:
+ "https://example.com/browser/browser/components/newtab/test/browser/ds_layout.json",
+ }),
+ ],
+ [
+ "browser.newtabpage.activity-stream.discoverystream.endpoints",
+ "https://example.com",
+ ],
+ ],
+ });
+}
+
+async function resetPrefs() {
+ // We set 5 prefs in setupPrefs, so we should reset 5 prefs.
+ // 1 popPrefEnv from pushPrefEnv
+ // and 4 popPrefEnv happen internally in setDefaultTopSites.
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+}
+
+let initialHeight;
+let initialWidth;
+function setSize(width, height) {
+ initialHeight = window.innerHeight;
+ initialWidth = window.innerWidth;
+ let resizePromise = BrowserTestUtils.waitForEvent(window, "resize", false);
+ window.resizeTo(width, height);
+ return resizePromise;
+}
+
+function resetSize() {
+ let resizePromise = BrowserTestUtils.waitForEvent(window, "resize", false);
+ window.resizeTo(initialWidth, initialHeight);
+ return resizePromise;
+}
+
+add_task(async function test_newtab_last_LinkMenu() {
+ await setupPrefs();
+
+ // Open about:newtab without using the default load listener
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false
+ );
+
+ // Specially wait for potentially preloaded browsers
+ let browser = tab.linkedBrowser;
+ await waitForPreloaded(browser);
+
+ // Wait for React to render something
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ SpecialPowers.spawn(
+ browser,
+ [],
+ () => content.document.getElementById("root").children.length
+ ),
+ "Should render activity stream content"
+ );
+
+ // Set the window to a small enough size to trigger menus that might overflow.
+ await setSize(600, 450);
+
+ // Test context menu position for topsites.
+ await SpecialPowers.spawn(browser, [], async () => {
+ // Topsites might not be ready, so wait for the button.
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ ".top-site-outer:nth-child(2n) .context-menu-button"
+ ),
+ "Wait for the Pocket card and button"
+ );
+ const topsiteOuter = content.document.querySelector(
+ ".top-site-outer:nth-child(2n)"
+ );
+ const topsiteContextMenuButton = topsiteOuter.querySelector(
+ ".context-menu-button"
+ );
+
+ topsiteContextMenuButton.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => topsiteOuter.classList.contains("active"),
+ "Wait for the menu to be active"
+ );
+
+ is(
+ content.window.scrollMaxX,
+ 0,
+ "there should be no horizontal scroll bar"
+ );
+ });
+
+ // Test context menu position for topstories.
+ await SpecialPowers.spawn(browser, [], async () => {
+ // Pocket section might take a bit more time to load,
+ // so wait for the button to be ready.
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ ".ds-card:nth-child(1n) .context-menu-button"
+ ),
+ "Wait for the Pocket card and button"
+ );
+
+ const dsCard = content.document.querySelector(".ds-card:nth-child(1n)");
+ const dsCarContextMenuButton = dsCard.querySelector(".context-menu-button");
+
+ dsCarContextMenuButton.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => dsCard.classList.contains("active"),
+ "Wait for the menu to be active"
+ );
+
+ is(
+ content.window.scrollMaxX,
+ 0,
+ "there should be no horizontal scroll bar"
+ );
+ });
+
+ // Resetting the window size to what it was.
+ await resetSize();
+ // Resetting prefs we set for this test.
+ await resetPrefs();
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/newtab/test/browser/browser_newtab_overrides.js b/browser/components/newtab/test/browser/browser_newtab_overrides.js
new file mode 100644
index 0000000000..ce7d82881f
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_newtab_overrides.js
@@ -0,0 +1,138 @@
+"use strict";
+
+const { AboutNewTab } = ChromeUtils.import(
+ "resource:///modules/AboutNewTab.jsm"
+);
+
+registerCleanupFunction(() => {
+ AboutNewTab.resetNewTabURL();
+});
+
+function nextChangeNotificationPromise(aNewURL, testMessage) {
+ return TestUtils.topicObserved(
+ "newtab-url-changed",
+ function observer(aSubject, aData) {
+ Assert.equal(aData, aNewURL, testMessage);
+ return true;
+ }
+ );
+}
+
+/*
+ * Tests that the default newtab page is always returned when one types "about:newtab" in the URL bar,
+ * even when overridden.
+ */
+add_task(async function redirector_ignores_override() {
+ let overrides = ["chrome://browser/content/aboutRobots.xhtml", "about:home"];
+
+ for (let overrideURL of overrides) {
+ let notificationPromise = nextChangeNotificationPromise(
+ overrideURL,
+ `newtab page now points to ${overrideURL}`
+ );
+ AboutNewTab.newTabURL = overrideURL;
+
+ await notificationPromise;
+ Assert.ok(AboutNewTab.newTabURLOverridden, "url has been overridden");
+
+ let tabOptions = {
+ gBrowser,
+ url: "about:newtab",
+ };
+
+ /*
+ * Simulate typing "about:newtab" in the url bar.
+ *
+ * Bug 1240169 - We expect the redirector to lead the user to "about:newtab", the default URL,
+ * due to invoking AboutRedirector. A user interacting with the chrome otherwise would lead
+ * to the overriding URLs.
+ */
+ await BrowserTestUtils.withNewTab(tabOptions, async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ Assert.equal(content.location.href, "about:newtab", "Got right URL");
+ Assert.equal(
+ content.document.location.href,
+ "about:newtab",
+ "Got right URL"
+ );
+ Assert.notEqual(
+ content.document.nodePrincipal,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ "activity stream principal should not match systemPrincipal"
+ );
+ });
+ });
+ }
+});
+
+/*
+ * Tests loading an overridden newtab page by simulating opening a newtab page from chrome
+ */
+add_task(async function override_loads_in_browser() {
+ let overrides = [
+ "chrome://browser/content/aboutRobots.xhtml",
+ "about:home",
+ " about:home",
+ ];
+
+ for (let overrideURL of overrides) {
+ let notificationPromise = nextChangeNotificationPromise(
+ overrideURL.trim(),
+ `newtab page now points to ${overrideURL}`
+ );
+ AboutNewTab.newTabURL = overrideURL;
+
+ await notificationPromise;
+ Assert.ok(AboutNewTab.newTabURLOverridden, "url has been overridden");
+
+ // simulate a newtab open as a user would
+ BrowserOpenTab();
+
+ let browser = gBrowser.selectedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await SpecialPowers.spawn(browser, [{ url: overrideURL }], async args => {
+ Assert.equal(content.location.href, args.url.trim(), "Got right URL");
+ Assert.equal(
+ content.document.location.href,
+ args.url.trim(),
+ "Got right URL"
+ );
+ });
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
+
+/*
+ * Tests edge cases when someone overrides the newtabpage with whitespace
+ */
+add_task(async function override_blank_loads_in_browser() {
+ let overrides = ["", " ", "\n\t", " about:blank"];
+
+ for (let overrideURL of overrides) {
+ let notificationPromise = nextChangeNotificationPromise(
+ "about:blank",
+ "newtab page now points to about:blank"
+ );
+ AboutNewTab.newTabURL = overrideURL;
+
+ await notificationPromise;
+ Assert.ok(AboutNewTab.newTabURLOverridden, "url has been overridden");
+
+ // simulate a newtab open as a user would
+ BrowserOpenTab();
+
+ let browser = gBrowser.selectedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ Assert.equal(content.location.href, "about:blank", "Got right URL");
+ Assert.equal(
+ content.document.location.href,
+ "about:blank",
+ "Got right URL"
+ );
+ });
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
diff --git a/browser/components/newtab/test/browser/browser_newtab_ping.js b/browser/components/newtab/test/browser/browser_newtab_ping.js
new file mode 100644
index 0000000000..42ff22a57d
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_newtab_ping.js
@@ -0,0 +1,216 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AboutNewTab } = ChromeUtils.import(
+ "resource:///modules/AboutNewTab.jsm"
+);
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+let sendTriggerMessageSpy;
+
+add_setup(function () {
+ let sandbox = sinon.createSandbox();
+ sendTriggerMessageSpy = sandbox.spy(ASRouter, "sendTriggerMessage");
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
+
+add_task(async function test_newtab_tab_close_sends_ping() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.activity-stream.telemetry", true]],
+ });
+
+ Services.fog.testResetFOG();
+ sendTriggerMessageSpy.resetHistory();
+ let TelemetryFeed =
+ AboutNewTab.activityStream.store.feeds.get("feeds.telemetry");
+ TelemetryFeed.init(); // INIT action doesn't happen by default.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false // waitForLoad; about:newtab is cached so this would never resolve
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => sendTriggerMessageSpy.called,
+ "After about:newtab finishes loading"
+ );
+ sendTriggerMessageSpy.resetHistory();
+
+ await BrowserTestUtils.waitForCondition(
+ () => !!Glean.newtab.opened.testGetValue("newtab"),
+ "We expect the newtab open to be recorded"
+ );
+ let record = Glean.newtab.opened.testGetValue("newtab");
+ Assert.equal(record.length, 1, "Should only be one open");
+ const sessionId = record[0].extra.newtab_visit_id;
+ Assert.ok(!!sessionId, "newtab_visit_id must be present");
+
+ let pingSubmitted = false;
+ GleanPings.newtab.testBeforeNextSubmit(reason => {
+ pingSubmitted = true;
+ Assert.equal(reason, "newtab_session_end");
+ record = Glean.newtab.closed.testGetValue("newtab");
+ Assert.equal(record.length, 1, "Should only have one close");
+ Assert.equal(
+ record[0].extra.newtab_visit_id,
+ sessionId,
+ "Should've closed the session we opened"
+ );
+ Assert.ok(Glean.newtabSearch.enabled.testGetValue());
+ Assert.ok(Glean.topsites.enabled.testGetValue());
+ Assert.ok(Glean.topsites.sponsoredEnabled.testGetValue());
+ Assert.ok(Glean.pocket.enabled.testGetValue());
+ Assert.ok(Glean.pocket.sponsoredStoriesEnabled.testGetValue());
+ Assert.equal(false, Glean.pocket.isSignedIn.testGetValue());
+ });
+
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.waitForCondition(
+ () => pingSubmitted,
+ "We expect the ping to have submitted."
+ );
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_newtab_tab_nav_sends_ping() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.activity-stream.telemetry", true]],
+ });
+
+ Services.fog.testResetFOG();
+ sendTriggerMessageSpy.resetHistory();
+ let TelemetryFeed =
+ AboutNewTab.activityStream.store.feeds.get("feeds.telemetry");
+ TelemetryFeed.init(); // INIT action doesn't happen by default.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false // waitForLoad; about:newtab is cached so this would never resolve
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => sendTriggerMessageSpy.called,
+ "After about:newtab finishes loading"
+ );
+ sendTriggerMessageSpy.resetHistory();
+
+ await BrowserTestUtils.waitForCondition(
+ () => !!Glean.newtab.opened.testGetValue("newtab"),
+ "We expect the newtab open to be recorded"
+ );
+ let record = Glean.newtab.opened.testGetValue("newtab");
+ Assert.equal(record.length, 1, "Should only be one open");
+ const sessionId = record[0].extra.newtab_visit_id;
+ Assert.ok(!!sessionId, "newtab_visit_id must be present");
+
+ let pingSubmitted = false;
+ GleanPings.newtab.testBeforeNextSubmit(reason => {
+ pingSubmitted = true;
+ Assert.equal(reason, "newtab_session_end");
+ record = Glean.newtab.closed.testGetValue("newtab");
+ Assert.equal(record.length, 1, "Should only have one close");
+ Assert.equal(
+ record[0].extra.newtab_visit_id,
+ sessionId,
+ "Should've closed the session we opened"
+ );
+ Assert.ok(Glean.newtabSearch.enabled.testGetValue());
+ Assert.ok(Glean.topsites.enabled.testGetValue());
+ Assert.ok(Glean.topsites.sponsoredEnabled.testGetValue());
+ Assert.ok(Glean.pocket.enabled.testGetValue());
+ Assert.ok(Glean.pocket.sponsoredStoriesEnabled.testGetValue());
+ Assert.equal(false, Glean.pocket.isSignedIn.testGetValue());
+ });
+
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:mozilla");
+ await BrowserTestUtils.waitForCondition(
+ () => pingSubmitted,
+ "We expect the ping to have submitted."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_newtab_doesnt_send_nimbus() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.activity-stream.telemetry", true]],
+ });
+
+ let doEnrollmentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "glean",
+ value: { newtabPingEnabled: false },
+ });
+ Services.fog.testResetFOG();
+ let TelemetryFeed =
+ AboutNewTab.activityStream.store.feeds.get("feeds.telemetry");
+ TelemetryFeed.init(); // INIT action doesn't happen by default.
+ sendTriggerMessageSpy.resetHistory();
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false // waitForLoad; about:newtab is cached so this would never resolve
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => sendTriggerMessageSpy.called,
+ "After about:newtab finishes loading"
+ );
+ sendTriggerMessageSpy.resetHistory();
+
+ await BrowserTestUtils.waitForCondition(
+ () => !!Glean.newtab.opened.testGetValue("newtab"),
+ "We expect the newtab open to be recorded"
+ );
+ let record = Glean.newtab.opened.testGetValue("newtab");
+ Assert.equal(record.length, 1, "Should only be one open");
+ const sessionId = record[0].extra.newtab_visit_id;
+ Assert.ok(!!sessionId, "newtab_visit_id must be present");
+
+ GleanPings.newtab.testBeforeNextSubmit(() => {
+ Assert.ok(false, "Must not submit ping!");
+ });
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:mozilla");
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.waitForCondition(() => {
+ let { sessions } =
+ AboutNewTab.activityStream.store.feeds.get("feeds.telemetry");
+ return !Array.from(sessions.entries()).filter(
+ ([k, v]) => v.session_id === sessionId
+ ).length;
+ }, "Waiting for sessions to clean up.");
+ // Session ended without a ping being sent. Success!
+ await doEnrollmentCleanup();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_newtab_categorization_sends_ping() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.activity-stream.telemetry", true]],
+ });
+
+ Services.fog.testResetFOG();
+ sendTriggerMessageSpy.resetHistory();
+ let TelemetryFeed =
+ AboutNewTab.activityStream.store.feeds.get("feeds.telemetry");
+ let pingSent = false;
+ GleanPings.newtab.testBeforeNextSubmit(reason => {
+ pingSent = true;
+ Assert.equal(reason, "component_init");
+ });
+ await TelemetryFeed.sendPageTakeoverData();
+ Assert.ok(pingSent, "ping was sent");
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/newtab/test/browser/browser_newtab_towindow.js b/browser/components/newtab/test/browser/browser_newtab_towindow.js
new file mode 100644
index 0000000000..d0a49e63f0
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_newtab_towindow.js
@@ -0,0 +1,45 @@
+// This test simulates opening the newtab page and moving it to a new window.
+// Links in the page should still work.
+add_task(async function test_newtab_to_window() {
+ await setTestTopSites();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false
+ );
+
+ let swappedPromise = BrowserTestUtils.waitForEvent(
+ tab.linkedBrowser,
+ "SwapDocShells"
+ );
+ let newWindow = gBrowser.replaceTabWithWindow(tab);
+ await swappedPromise;
+
+ is(
+ newWindow.gBrowser.selectedBrowser.currentURI.spec,
+ "about:newtab",
+ "about:newtab moved to window"
+ );
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(
+ newWindow.gBrowser,
+ "https://example.com/",
+ true
+ );
+
+ await BrowserTestUtils.synthesizeMouse(
+ `.top-sites a`,
+ 2,
+ 2,
+ { accelKey: true },
+ newWindow.gBrowser.selectedBrowser
+ );
+
+ await tabPromise;
+
+ is(newWindow.gBrowser.tabs.length, 2, "second page is opened");
+
+ BrowserTestUtils.removeTab(newWindow.gBrowser.selectedTab);
+ await BrowserTestUtils.closeWindow(newWindow);
+});
diff --git a/browser/components/newtab/test/browser/browser_newtab_trigger.js b/browser/components/newtab/test/browser/browser_newtab_trigger.js
new file mode 100644
index 0000000000..dbc1b71e21
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_newtab_trigger.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+let sendTriggerMessageSpy;
+let triggerMatch;
+
+add_setup(function () {
+ let sandbox = sinon.createSandbox();
+ sendTriggerMessageSpy = sandbox.spy(ASRouter, "sendTriggerMessage");
+ triggerMatch = sandbox.match({ id: "defaultBrowserCheck" });
+
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
+
+async function testPageTrigger(url, waitForLoad, expectedTrigger) {
+ sendTriggerMessageSpy.resetHistory();
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ url,
+ waitForLoad
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => sendTriggerMessageSpy.calledWith(expectedTrigger),
+ `After ${url} finishes loading`
+ );
+ Assert.ok(
+ sendTriggerMessageSpy.calledWith(expectedTrigger),
+ `Found the expected ${expectedTrigger.id} trigger`
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ sendTriggerMessageSpy.resetHistory();
+}
+
+add_task(function test_newtab_trigger() {
+ return testPageTrigger("about:newtab", false, triggerMatch);
+});
+
+add_task(function test_abouthome_trigger() {
+ return testPageTrigger("about:home", true, triggerMatch);
+});
diff --git a/browser/components/newtab/test/browser/browser_open_tab_focus.js b/browser/components/newtab/test/browser/browser_open_tab_focus.js
new file mode 100644
index 0000000000..5eea955260
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_open_tab_focus.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_open_tab_focus() {
+ await setTestTopSites();
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false
+ );
+ // Specially wait for potentially preloaded browsers
+ let browser = tab.linkedBrowser;
+ await waitForPreloaded(browser);
+ // Wait for React to render something
+ await SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector(".top-sites-list .top-site-button .title")
+ );
+ });
+
+ await BrowserTestUtils.synthesizeMouse(
+ `.top-sites-list .top-site-button .title`,
+ 2,
+ 2,
+ { accelKey: true },
+ browser
+ );
+
+ ok(
+ gBrowser.selectedTab === tab,
+ "The original tab is still the selected tab"
+ );
+ BrowserTestUtils.removeTab(gBrowser.tabs[2]); // example.org tab
+ BrowserTestUtils.removeTab(tab); // The original tab
+});
diff --git a/browser/components/newtab/test/browser/browser_remote_l10n.js b/browser/components/newtab/test/browser/browser_remote_l10n.js
new file mode 100644
index 0000000000..967236a721
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_remote_l10n.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RemoteL10n } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/RemoteL10n.sys.mjs"
+);
+
+const ID = "remote_l10n_test_string";
+const VALUE = "RemoteL10n string";
+const CONTENT = `${ID} = ${VALUE}`;
+
+add_setup(async () => {
+ const l10nRegistryInstance = L10nRegistry.getInstance();
+ const localProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+ const dirPath = PathUtils.join(
+ localProfileDir,
+ ...["settings", "main", "ms-language-packs", "browser", "newtab"]
+ );
+ const filePath = PathUtils.join(dirPath, "asrouter.ftl");
+
+ await IOUtils.makeDirectory(dirPath, {
+ ignoreExisting: true,
+ from: localProfileDir,
+ });
+ await IOUtils.writeUTF8(filePath, CONTENT, {
+ tmpPath: `${filePath}.tmp`,
+ });
+
+ // Remove any cached l10n resources, "cfr" is the cache key
+ // used for strings from the remote `asrouter.ftl` see RemoteL10n.sys.mjs
+ RemoteL10n.reloadL10n();
+ if (l10nRegistryInstance.hasSource("cfr")) {
+ l10nRegistryInstance.removeSources(["cfr"]);
+ }
+});
+
+add_task(async function test_TODO() {
+ let [{ value }] = await RemoteL10n.l10n.formatMessages([{ id: ID }]);
+
+ Assert.equal(value, VALUE, "Got back the string we wrote to disk");
+});
+
+// Test that the formatting helper works. This helper is lower-level than the
+// DOM localization apparatus, and as such doesn't require the weight of the
+// `browser` test framework, but it's nice to co-locate related tests.
+add_task(async function test_formatLocalizableText() {
+ let value = await RemoteL10n.formatLocalizableText({ string_id: ID });
+
+ Assert.equal(value, VALUE, "Got back the string we wrote to disk");
+
+ value = await RemoteL10n.formatLocalizableText("unchanged");
+
+ Assert.equal(value, "unchanged", "Got back the string provided");
+});
diff --git a/browser/components/newtab/test/browser/browser_topsites_annotation.js b/browser/components/newtab/test/browser/browser_topsites_annotation.js
new file mode 100644
index 0000000000..7e48868fca
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_topsites_annotation.js
@@ -0,0 +1,980 @@
+/* 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 clicking a tile.
+
+if (AppConstants.platform === "macosx") {
+ requestLongerTimeout(4);
+} else {
+ requestLongerTimeout(2);
+}
+
+ChromeUtils.defineESModuleGetters(this, {
+ NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs",
+});
+
+const OPEN_TYPE = {
+ CURRENT_BY_CLICK: 0,
+ NEWTAB_BY_CLICK: 1,
+ NEWTAB_BY_MIDDLECLICK: 2,
+ NEWTAB_BY_CONTEXTMENU: 3,
+ NEWWINDOW_BY_CONTEXTMENU: 4,
+ NEWWINDOW_BY_CONTEXTMENU_OF_TILE: 5,
+};
+
+const FRECENCY = {
+ TYPED: 2000,
+ VISITED: 100,
+ SPONSORED: -1,
+ BOOKMARKED: 2075,
+ MIDDLECLICK_TYPED: 100,
+ MIDDLECLICK_BOOKMARKED: 175,
+ NEWWINDOW_TYPED: 100,
+ NEWWINDOW_BOOKMARKED: 175,
+};
+
+const {
+ VISIT_SOURCE_ORGANIC,
+ VISIT_SOURCE_SPONSORED,
+ VISIT_SOURCE_BOOKMARKED,
+} = 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}`
+ );
+}
+
+async function waitForLocationChanged(destinationURL) {
+ // If nodeIconChanged of browserPlacesViews.js is called after the target node
+ // is lost during test, "No DOM node set for aPlacesNode" error occur. To avoid
+ // this failure, wait for the onLocationChange event that triggers
+ // nodeIconChanged to occur.
+ return new Promise(resolve => {
+ gBrowser.addTabsProgressListener({
+ async onLocationChange(aBrowser, aWebProgress, aRequest, aLocation) {
+ if (aLocation.spec === destinationURL) {
+ gBrowser.removeTabsProgressListener(this);
+ // Wait for an empty Promise to ensure to proceed our test after
+ // finishing the processing of other onLocatoinChanged events.
+ await Promise.resolve();
+ resolve();
+ }
+ },
+ });
+ });
+}
+
+async function openAndTest({
+ linkSelector,
+ linkURL,
+ redirectTo = null,
+ openType = OPEN_TYPE.CURRENT_BY_CLICK,
+ expected,
+}) {
+ const destinationURL = redirectTo || linkURL;
+
+ // Wait for content is ready.
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [linkSelector, linkURL],
+ async (selector, link) => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(selector).href === link
+ );
+ }
+ );
+
+ info("Open specific link by type and wait for loading.");
+ let promiseVisited = waitForVisitNotification(destinationURL);
+ if (openType === OPEN_TYPE.CURRENT_BY_CLICK) {
+ const onLoad = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ destinationURL
+ );
+ const onLocationChanged = waitForLocationChanged(destinationURL);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ linkSelector,
+ {},
+ gBrowser.selectedBrowser
+ );
+
+ await onLoad;
+ await onLocationChanged;
+ } else if (openType === OPEN_TYPE.NEWTAB_BY_CLICK) {
+ const onLoad = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ destinationURL,
+ true
+ );
+ const onLocationChanged = waitForLocationChanged(destinationURL);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ linkSelector,
+ { ctrlKey: true, metaKey: true },
+ gBrowser.selectedBrowser
+ );
+
+ const tab = await onLoad;
+ await onLocationChanged;
+ BrowserTestUtils.removeTab(tab);
+ } else if (openType === OPEN_TYPE.NEWTAB_BY_MIDDLECLICK) {
+ const onLoad = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ destinationURL,
+ true
+ );
+ const onLocationChanged = waitForLocationChanged(destinationURL);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ linkSelector,
+ { button: 1 },
+ gBrowser.selectedBrowser
+ );
+
+ const tab = await onLoad;
+ await onLocationChanged;
+ BrowserTestUtils.removeTab(tab);
+ } else if (openType === OPEN_TYPE.NEWTAB_BY_CONTEXTMENU) {
+ const onLoad = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ destinationURL,
+ true
+ );
+ const onLocationChanged = waitForLocationChanged(destinationURL);
+
+ const onPopup = BrowserTestUtils.waitForEvent(document, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ linkSelector,
+ { type: "contextmenu" },
+ gBrowser.selectedBrowser
+ );
+ await onPopup;
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ const openLinkMenuItem = contextMenu.querySelector(
+ "#context-openlinkintab"
+ );
+ contextMenu.activateItem(openLinkMenuItem);
+
+ const tab = await onLoad;
+ await onLocationChanged;
+ BrowserTestUtils.removeTab(tab);
+ } else if (openType === OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU) {
+ const onLoad = BrowserTestUtils.waitForNewWindow({ url: destinationURL });
+
+ const onPopup = BrowserTestUtils.waitForEvent(document, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ linkSelector,
+ { type: "contextmenu" },
+ gBrowser.selectedBrowser
+ );
+ await onPopup;
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ const openLinkMenuItem = contextMenu.querySelector("#context-openlink");
+ contextMenu.activateItem(openLinkMenuItem);
+
+ const win = await onLoad;
+ await BrowserTestUtils.closeWindow(win);
+ } else if (openType === OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU_OF_TILE) {
+ const onLoad = BrowserTestUtils.waitForNewWindow({ url: destinationURL });
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [linkSelector],
+ async selector => {
+ const link = content.document.querySelector(selector);
+ const list = link.closest("li");
+ const contextMenu = list.querySelector(".context-menu-button");
+ contextMenu.click();
+ const target = list.querySelector(
+ "[data-l10n-id=newtab-menu-open-new-window]"
+ );
+ target.click();
+ }
+ );
+
+ const win = await onLoad;
+ await BrowserTestUtils.closeWindow(win);
+ }
+ await promiseVisited;
+
+ info("Check database for the destination.");
+ await assertDatabase({ targetURL: destinationURL, expected });
+}
+
+async function pin(link) {
+ // Setup test tile.
+ NewTabUtils.pinnedLinks.pin(link, 0);
+ await toggleTopsitesPref();
+ await BrowserTestUtils.waitForCondition(() => {
+ const sites = AboutNewTab.getTopSites();
+ return (
+ sites?.[0]?.url === link.url &&
+ sites[0].sponsored_tile_id === link.sponsored_tile_id
+ );
+ }, "Waiting for top sites to be updated");
+}
+
+function unpin(link) {
+ NewTabUtils.pinnedLinks.unpin(link);
+}
+
+add_setup(async function () {
+ await clearHistoryAndBookmarks();
+ registerCleanupFunction(async () => {
+ await clearHistoryAndBookmarks();
+ });
+});
+
+add_task(async function basic() {
+ const SPONSORED_LINK = {
+ label: "test_label",
+ url: "https://example.com/",
+ sponsored_position: 1,
+ sponsored_tile_id: 12345,
+ sponsored_impression_url: "https://impression.example.com/",
+ sponsored_click_url: "https://click.example.com/",
+ };
+ const NORMAL_LINK = {
+ label: "test_label",
+ url: "https://example.com/",
+ };
+ const BOOKMARKS = [
+ {
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: Services.io.newURI("https://example.com/"),
+ title: "test bookmark",
+ },
+ ];
+
+ const testData = [
+ {
+ description: "Sponsored tile",
+ link: SPONSORED_LINK,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ },
+ {
+ description: "Sponsored tile in new tab by click with key",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_CLICK,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ },
+ {
+ description: "Sponsored tile in new tab by middle click",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_MIDDLECLICK,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ },
+ {
+ description: "Sponsored tile in new tab by context menu",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_CONTEXTMENU,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ },
+ {
+ description: "Sponsored tile in new window by context menu",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ },
+ {
+ description: "Sponsored tile in new window by context menu of tile",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU_OF_TILE,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ },
+ {
+ description: "Bookmarked result",
+ link: NORMAL_LINK,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_BOOKMARKED,
+ frecency: FRECENCY.BOOKMARKED,
+ },
+ },
+ {
+ description: "Bookmarked result in new tab by click with key",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_CLICK,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_BOOKMARKED,
+ frecency: FRECENCY.BOOKMARKED,
+ },
+ },
+ {
+ description: "Bookmarked result in new tab by middle click",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_MIDDLECLICK,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_BOOKMARKED,
+ frecency: FRECENCY.MIDDLECLICK_BOOKMARKED,
+ },
+ },
+ {
+ description: "Bookmarked result in new tab by context menu",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_CONTEXTMENU,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_BOOKMARKED,
+ frecency: FRECENCY.MIDDLECLICK_BOOKMARKED,
+ },
+ },
+ {
+ description: "Bookmarked result in new window by context menu",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_BOOKMARKED,
+ frecency: FRECENCY.NEWWINDOW_BOOKMARKED,
+ },
+ },
+ {
+ description: "Bookmarked result in new window by context menu of tile",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU_OF_TILE,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_BOOKMARKED,
+ frecency: FRECENCY.BOOKMARKED,
+ },
+ },
+ {
+ description: "Sponsored and bookmarked result",
+ link: SPONSORED_LINK,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.BOOKMARKED,
+ },
+ },
+ {
+ description:
+ "Sponsored and bookmarked result in new tab by click with key",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_CLICK,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.BOOKMARKED,
+ },
+ },
+ {
+ description: "Sponsored and bookmarked result in new tab by middle click",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_MIDDLECLICK,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.MIDDLECLICK_BOOKMARKED,
+ },
+ },
+ {
+ description: "Sponsored and bookmarked result in new tab by context menu",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_CONTEXTMENU,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.MIDDLECLICK_BOOKMARKED,
+ },
+ },
+ {
+ description:
+ "Sponsored and bookmarked result in new window by context menu",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.NEWWINDOW_BOOKMARKED,
+ },
+ },
+ {
+ description:
+ "Sponsored and bookmarked result in new window by context menu of tile",
+ link: SPONSORED_LINK,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU_OF_TILE,
+ bookmarks: BOOKMARKS,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.BOOKMARKED,
+ },
+ },
+ {
+ description: "Organic tile",
+ link: NORMAL_LINK,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.TYPED,
+ },
+ },
+ {
+ description: "Organic tile in new tab by click with key",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_CLICK,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.TYPED,
+ },
+ },
+ {
+ description: "Organic tile in new tab by middle click",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_MIDDLECLICK,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.MIDDLECLICK_TYPED,
+ },
+ },
+ {
+ description: "Organic tile in new tab by context menu",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWTAB_BY_CONTEXTMENU,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.MIDDLECLICK_TYPED,
+ },
+ },
+ {
+ description: "Organic tile in new window by context menu",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.NEWWINDOW_TYPED,
+ },
+ },
+ {
+ description: "Organic tile in new window by context menu of tile",
+ link: NORMAL_LINK,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU_OF_TILE,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.TYPED,
+ },
+ },
+ ];
+
+ for (const { description, link, openType, bookmarks, expected } of testData) {
+ info(description);
+
+ await BrowserTestUtils.withNewTab("about:home", async () => {
+ // Setup test tile.
+ await pin(link);
+
+ for (const bookmark of bookmarks || []) {
+ await PlacesUtils.bookmarks.insert(bookmark);
+ }
+
+ await openAndTest({
+ linkSelector: ".top-site-button",
+ linkURL: link.url,
+ openType,
+ expected,
+ });
+
+ await clearHistoryAndBookmarks();
+
+ unpin(link);
+ });
+ }
+});
+
+add_task(async function redirection() {
+ await BrowserTestUtils.withNewTab("about:home", async () => {
+ const redirectTo = "https://example.com/";
+ const link = {
+ label: "test_label",
+ url: "https://example.com/browser/browser/components/newtab/test/browser/redirect_to.sjs?/",
+ sponsored_position: 1,
+ sponsored_tile_id: 12345,
+ sponsored_impression_url: "https://impression.example.com/",
+ sponsored_click_url: "https://click.example.com/",
+ };
+
+ // Setup test tile.
+ await pin(link);
+
+ // Test with new tab.
+ await openAndTest({
+ linkSelector: ".top-site-button",
+ linkURL: link.url,
+ redirectTo,
+ openType: OPEN_TYPE.NEWTAB_BY_CLICK,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+
+ // Check for URL causes the redirection.
+ await assertDatabase({
+ targetURL: link.url,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ });
+ await clearHistoryAndBookmarks();
+
+ // Test with same tab.
+ await openAndTest({
+ linkSelector: ".top-site-button",
+ linkURL: link.url,
+ redirectTo,
+ openType: OPEN_TYPE.NEWTAB_BY_CLICK,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+
+ // Check for URL causes the redirection.
+ await assertDatabase({
+ targetURL: link.url,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ });
+ await clearHistoryAndBookmarks();
+ unpin(link);
+ });
+});
+
+add_task(async function inherit() {
+ const host = "https://example.com/";
+ const sameBaseDomainHost = "https://www.example.com/";
+ const path = "browser/browser/components/newtab/test/browser/";
+ const firstURL = `${host}${path}annotation_first.html`;
+ const secondURL = `${host}${path}annotation_second.html`;
+ const thirdURL = `${sameBaseDomainHost}${path}annotation_third.html`;
+ const outsideURL = "https://example.org/";
+
+ await BrowserTestUtils.withNewTab("about:home", async () => {
+ const link = {
+ label: "first",
+ url: firstURL,
+ sponsored_position: 1,
+ sponsored_tile_id: 12345,
+ sponsored_impression_url: "https://impression.example.com/",
+ sponsored_click_url: "https://click.example.com/",
+ };
+
+ // Setup test tile.
+ await pin(link);
+
+ info("Open the tile to show first page in same tab");
+ await openAndTest({
+ linkSelector: ".top-site-button",
+ linkURL: link.url,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ });
+
+ info("Open link on first page to show second page in new window");
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: secondURL,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+ await PlacesTestUtils.clearHistoryVisits();
+
+ info(
+ "Open link on first page to show second page in new tab by click with key"
+ );
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: secondURL,
+ openType: OPEN_TYPE.NEWTAB_BY_CLICK,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+ await PlacesTestUtils.clearHistoryVisits();
+
+ info(
+ "Open link on first page to show second page in new tab by middle click"
+ );
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: secondURL,
+ openType: OPEN_TYPE.NEWTAB_BY_MIDDLECLICK,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+ await PlacesTestUtils.clearHistoryVisits();
+
+ info("Open link on first page to show second page in same tab");
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: secondURL,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+
+ info("Open link on first page to show second page in new window");
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: thirdURL,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+ await PlacesTestUtils.clearHistoryVisits();
+
+ info(
+ "Open link on second page to show third page in new tab by context menu"
+ );
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: thirdURL,
+ openType: OPEN_TYPE.NEWTAB_BY_CONTEXTMENU,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+ await PlacesTestUtils.clearHistoryVisits();
+
+ info(
+ "Open link on second page to show third page in new tab by middle click"
+ );
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: thirdURL,
+ openType: OPEN_TYPE.NEWTAB_BY_MIDDLECLICK,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+ await PlacesTestUtils.clearHistoryVisits();
+
+ info("Open link on second page to show third page in same tab");
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: thirdURL,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+
+ info("Open link on third page to show outside domain page in same tab");
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: outsideURL,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.VISITED,
+ },
+ });
+
+ info("Visit URL that has the same domain as sponsored link from URL bar");
+ const onLoad = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ host
+ );
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: host,
+ waitForFocus: SimpleTest.waitForFocus,
+ });
+ let promiseVisited = waitForVisitNotification(host);
+ EventUtils.synthesizeKey("KEY_Enter");
+ await onLoad;
+ await promiseVisited;
+
+ await assertDatabase({
+ targetURL: host,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ triggerURL: link.url,
+ },
+ });
+
+ unpin(link);
+ await clearHistoryAndBookmarks();
+ });
+});
+
+add_task(async function timeout() {
+ const base =
+ "https://example.com/browser/browser/components/newtab/test/browser";
+ const firstURL = `${base}/annotation_first.html`;
+ const secondURL = `${base}/annotation_second.html`;
+
+ await BrowserTestUtils.withNewTab("about:home", async () => {
+ const link = {
+ label: "test",
+ url: firstURL,
+ sponsored_position: 1,
+ sponsored_tile_id: 12345,
+ sponsored_impression_url: "https://impression.example.com/",
+ sponsored_click_url: "https://click.example.com/",
+ };
+
+ // Setup a test tile.
+ await pin(link);
+
+ info("Open the tile");
+ await openAndTest({
+ linkSelector: ".top-site-button",
+ linkURL: link.url,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ });
+
+ info("Set timeout second");
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.places.sponsoredSession.timeoutSecs", 1]],
+ });
+
+ info("Wait 1 sec");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000));
+
+ info("Open link on first page to show second page in new window");
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: secondURL,
+ openType: OPEN_TYPE.NEWWINDOW_BY_CONTEXTMENU,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.VISITED,
+ },
+ });
+ await PlacesTestUtils.clearHistoryVisits();
+
+ info(
+ "Open link on first page to show second page in new tab by click with key"
+ );
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: secondURL,
+ openType: OPEN_TYPE.NEWTAB_BY_CLICK,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.VISITED,
+ },
+ });
+ await PlacesTestUtils.clearHistoryVisits();
+
+ info(
+ "Open link on first page to show second page in new tab by middle click"
+ );
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: secondURL,
+ openType: OPEN_TYPE.NEWTAB_BY_MIDDLECLICK,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.VISITED,
+ },
+ });
+ await PlacesTestUtils.clearHistoryVisits();
+
+ info("Open link on first page to show second page");
+ await openAndTest({
+ linkSelector: "a",
+ linkURL: secondURL,
+ expected: {
+ source: VISIT_SOURCE_ORGANIC,
+ frecency: FRECENCY.VISITED,
+ },
+ });
+
+ unpin(link);
+ await clearHistoryAndBookmarks();
+ });
+});
+
+add_task(async function fixup() {
+ await BrowserTestUtils.withNewTab("about:home", async () => {
+ const destinationURL = "https://example.com/?a";
+ const link = {
+ label: "test",
+ url: "https://example.com?a",
+ sponsored_position: 1,
+ sponsored_tile_id: 12345,
+ sponsored_impression_url: "https://impression.example.com/",
+ sponsored_click_url: "https://click.example.com/",
+ };
+
+ info("Setup pin");
+ await pin(link);
+
+ info("Click sponsored tile");
+ let promiseVisited = waitForVisitNotification(destinationURL);
+ const onLoad = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ destinationURL
+ );
+ const onLocationChanged = waitForLocationChanged(destinationURL);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ".top-site-button",
+ {},
+ gBrowser.selectedBrowser
+ );
+ await onLoad;
+ await onLocationChanged;
+ await promiseVisited;
+
+ info("Check the DB");
+ await assertDatabase({
+ targetURL: destinationURL,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ });
+
+ info("Clean up");
+ unpin(link);
+ await clearHistoryAndBookmarks();
+ });
+});
+
+add_task(async function noTriggeringURL() {
+ await BrowserTestUtils.withNewTab("about:home", async browser => {
+ Services.telemetry.clearScalars();
+
+ const dummyTriggeringSponsoredURL =
+ "https://example.com/dummyTriggeringSponsoredURL";
+ const targetURL = "https://example.com/";
+
+ info("Setup dummy triggering sponsored URL");
+ browser.setAttribute("triggeringSponsoredURL", dummyTriggeringSponsoredURL);
+ browser.setAttribute("triggeringSponsoredURLVisitTimeMS", Date.now());
+
+ info("Open URL whose host is the same as dummy triggering sponsored URL");
+ let promiseVisited = waitForVisitNotification(targetURL);
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: targetURL,
+ waitForFocus: SimpleTest.waitForFocus,
+ });
+ const onLoad = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ targetURL
+ );
+ EventUtils.synthesizeKey("KEY_Enter");
+ await onLoad;
+ await promiseVisited;
+
+ info("Check DB");
+ await assertDatabase({
+ targetURL,
+ expected: {
+ source: VISIT_SOURCE_SPONSORED,
+ frecency: FRECENCY.SPONSORED,
+ },
+ });
+
+ info("Check telemetry");
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true);
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "places.sponsored_visit_no_triggering_url",
+ 1
+ );
+
+ await clearHistoryAndBookmarks();
+ });
+});
diff --git a/browser/components/newtab/test/browser/browser_topsites_contextMenu_options.js b/browser/components/newtab/test/browser/browser_topsites_contextMenu_options.js
new file mode 100644
index 0000000000..c744e8ee01
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_topsites_contextMenu_options.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/. */
+
+"use strict";
+
+test_newtab({
+ async before() {
+ // Some reason test-linux1804-64-qr/debug can end up with example.com, so
+ // clear history so we only have the expected default top sites.
+ await clearHistoryAndBookmarks();
+ await setDefaultTopSites();
+ },
+ // Test verifies the menu options for a default top site.
+ test: async function defaultTopSites_menuOptions() {
+ const siteSelector = ".top-site-outer:not(.search-shortcut, .placeholder)";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(siteSelector),
+ "Topsite tippytop icon not found"
+ );
+
+ const contextMenuItems = await content.openContextMenuAndGetOptions(
+ siteSelector
+ );
+
+ Assert.equal(contextMenuItems.length, 5, "Number of options is correct");
+
+ const expectedItemsText = [
+ "Pin",
+ "Edit",
+ "Open in a New Window",
+ "Open in a New Private Window",
+ "Dismiss",
+ ];
+
+ for (let i = 0; i < contextMenuItems.length; i++) {
+ await ContentTaskUtils.waitForCondition(
+ () => contextMenuItems[i].textContent === expectedItemsText[i],
+ "Name option is correct"
+ );
+ }
+ },
+});
+
+test_newtab({
+ before: setDefaultTopSites,
+ // Test verifies that the next top site in queue replaces a dismissed top site.
+ test: async function defaultTopSites_dismiss() {
+ const siteSelector = ".top-site-outer:not(.search-shortcut, .placeholder)";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(siteSelector),
+ "Topsite tippytop icon not found"
+ );
+
+ // Don't count search topsites
+ const defaultTopSitesNumber =
+ content.document.querySelectorAll(siteSelector).length;
+ Assert.equal(defaultTopSitesNumber, 5, "5 top sites are loaded by default");
+
+ // Skip the search topsites select the second default topsite
+ const secondTopSite = content.document
+ .querySelectorAll(siteSelector)[1]
+ .getAttribute("href");
+
+ const contextMenuItems = await content.openContextMenuAndGetOptions(
+ siteSelector
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => contextMenuItems[4].textContent === "Dismiss",
+ "'Dismiss' is the 5th item in the context menu list"
+ );
+
+ contextMenuItems[4].querySelector("button").click();
+
+ // Wait for the topsite to be dismissed and the second one to replace it
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(siteSelector).getAttribute("href") ===
+ secondTopSite,
+ "First default topsite was dismissed"
+ );
+
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelectorAll(siteSelector).length === 4,
+ "4 top sites are displayed after one of them is dismissed"
+ );
+ },
+ async after() {
+ await new Promise(resolve => NewTabUtils.undoAll(resolve));
+ },
+});
+
+test_newtab({
+ before: setDefaultTopSites,
+ test: async function searchTopSites_dismiss() {
+ const siteSelector = ".search-shortcut";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelectorAll(siteSelector).length === 1,
+ "1 search topsites is loaded by default"
+ );
+
+ const contextMenuItems = await content.openContextMenuAndGetOptions(
+ siteSelector
+ );
+ is(
+ contextMenuItems.length,
+ 2,
+ "Search TopSites should only have Unpin and Dismiss"
+ );
+
+ // Unpin
+ contextMenuItems[0].querySelector("button").click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelectorAll(siteSelector).length === 1,
+ "1 search topsite displayed after we unpin the other one"
+ );
+ },
+ after: () => {
+ // Required for multiple test runs in the same browser, pref is used to
+ // prevent pinning the same search topsite twice
+ Services.prefs.clearUserPref(
+ "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts.havePinned"
+ );
+ },
+});
diff --git a/browser/components/newtab/test/browser/browser_topsites_section.js b/browser/components/newtab/test/browser/browser_topsites_section.js
new file mode 100644
index 0000000000..9cbb49bf2f
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_topsites_section.js
@@ -0,0 +1,299 @@
+"use strict";
+
+// Check TopSites edit modal and overlay show up.
+test_newtab({
+ before: setTestTopSites,
+ // it should be able to click the topsites add button to reveal the add top site modal and overlay.
+ test: async function topsites_edit() {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".top-sites .context-menu-button"),
+ "Should find a visible topsite context menu button [topsites_edit]"
+ );
+
+ // Open the section context menu.
+ content.document.querySelector(".top-sites .context-menu-button").click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".top-sites .context-menu"),
+ "Should find a visible topsite context menu [topsites_edit]"
+ );
+
+ const topsitesAddBtn = content.document.querySelector(
+ ".top-sites li:nth-child(2) button"
+ );
+ topsitesAddBtn.click();
+
+ let found = content.document.querySelector(".topsite-form");
+ ok(found && !found.hidden, "Should find a visible topsite form");
+
+ found = content.document.querySelector(".modalOverlayOuter");
+ ok(found && !found.hidden, "Should find a visible overlay");
+ },
+});
+
+// Test pin/unpin context menu options.
+test_newtab({
+ before: setDefaultTopSites,
+ // it should pin the website when we click the first option of the topsite context menu.
+ test: async function topsites_pin_unpin() {
+ const siteSelector = ".top-site-outer:not(.search-shortcut, .placeholder)";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(siteSelector),
+ "Topsite tippytop icon not found"
+ );
+ // There are only topsites on the page, the selector with find the first topsite menu button.
+ let topsiteEl = content.document.querySelector(siteSelector);
+ let topsiteContextBtn = topsiteEl.querySelector(".context-menu-button");
+ topsiteContextBtn.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => topsiteEl.querySelector(".top-sites-list .context-menu"),
+ "No context menu found"
+ );
+
+ let contextMenu = topsiteEl.querySelector(".top-sites-list .context-menu");
+ ok(contextMenu, "Should find a topsite context menu");
+
+ const pinUnpinTopsiteBtn = contextMenu.querySelector(
+ ".top-sites .context-menu-item button"
+ );
+ // Pin the topsite.
+ pinUnpinTopsiteBtn.click();
+
+ // Need to wait for pin action.
+ await ContentTaskUtils.waitForCondition(
+ () => topsiteEl.querySelector(".icon-pin-small"),
+ "No pinned icon found"
+ );
+
+ let pinnedIcon = topsiteEl.querySelectorAll(".icon-pin-small").length;
+ is(pinnedIcon, 1, "should find 1 pinned topsite");
+
+ // Unpin the topsite.
+ topsiteContextBtn = topsiteEl.querySelector(".context-menu-button");
+ ok(topsiteContextBtn, "Should find a context menu button");
+ topsiteContextBtn.click();
+ topsiteEl.querySelector(".context-menu-item button").click();
+
+ // Need to wait for unpin action.
+ await ContentTaskUtils.waitForCondition(
+ () => !topsiteEl.querySelector(".icon-pin-small"),
+ "Topsite should be unpinned"
+ );
+ },
+});
+
+// Check Topsites add
+test_newtab({
+ before: setTestTopSites,
+ // it should be able to click the topsites edit button to reveal the edit topsites modal and overlay.
+ test: async function topsites_add() {
+ let nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+ content.window.HTMLInputElement.prototype,
+ "value"
+ ).set;
+ let event = new content.Event("input", { bubbles: true });
+
+ // Wait for context menu button to load
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".top-sites .context-menu-button"),
+ "Should find a visible topsite context menu button [topsites_add]"
+ );
+
+ content.document.querySelector(".top-sites .context-menu-button").click();
+
+ // Wait for context menu to load
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".top-sites .context-menu"),
+ "Should find a visible topsite context menu [topsites_add]"
+ );
+
+ // Find topsites edit button
+ const topsitesAddBtn = content.document.querySelector(
+ ".top-sites li:nth-child(2) button"
+ );
+
+ topsitesAddBtn.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".modalOverlayOuter"),
+ "No overlay found"
+ );
+
+ let found = content.document.querySelector(".modalOverlayOuter");
+ ok(found && !found.hidden, "Should find a visible overlay");
+
+ // Write field title
+ let fieldTitle = content.document.querySelector(".field input");
+ ok(fieldTitle && !fieldTitle.hidden, "Should find field title input");
+
+ nativeInputValueSetter.call(fieldTitle, "Bugzilla");
+ fieldTitle.dispatchEvent(event);
+ is(fieldTitle.value, "Bugzilla", "The field title should match");
+
+ // Write field url
+ let fieldURL = content.document.querySelector(".field.url input");
+ ok(fieldURL && !fieldURL.hidden, "Should find field url input");
+
+ nativeInputValueSetter.call(fieldURL, "https://bugzilla.mozilla.org");
+ fieldURL.dispatchEvent(event);
+ is(
+ fieldURL.value,
+ "https://bugzilla.mozilla.org",
+ "The field url should match"
+ );
+
+ // Click the "Add" button
+ let addBtn = content.document.querySelector(".done");
+ addBtn.click();
+
+ // Wait for Topsite to be populated
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector("[href='https://bugzilla.mozilla.org']"),
+ "No Topsite found"
+ );
+
+ // Remove topsite after test is complete
+ let topsiteContextBtn = content.document.querySelector(
+ ".top-sites-list li:nth-child(1) .context-menu-button"
+ );
+ topsiteContextBtn.click();
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".top-sites-list .context-menu"),
+ "No context menu found"
+ );
+
+ const dismissBtn = content.document.querySelector(
+ ".top-sites li:nth-child(7) button"
+ );
+ dismissBtn.click();
+
+ // Wait for Topsite to be removed
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ !content.document.querySelector(
+ "[href='https://bugzilla.mozilla.org']"
+ ),
+ "Topsite not removed"
+ );
+ },
+});
+
+test_newtab({
+ before: setDefaultTopSites,
+ test: async function test_search_topsite_keyword() {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".search-shortcut .title.pinned"),
+ "Wait for pinned search topsites"
+ );
+
+ const searchTopSites = content.document.querySelectorAll(".title.pinned");
+ ok(
+ searchTopSites.length >= 1,
+ "There should be at least 1 search topsites"
+ );
+
+ searchTopSites[0].click();
+
+ return searchTopSites[0].innerText.trim();
+ },
+ async after(searchTopSiteTag) {
+ ok(
+ gURLBar.focused,
+ "We clicked a search topsite the focus should be in location bar"
+ );
+ let engine = await Services.search.getEngineByAlias(searchTopSiteTag);
+
+ // We don't use UrlbarTestUtils.assertSearchMode here since the newtab
+ // testing scope doesn't integrate well with UrlbarTestUtils.
+ Assert.deepEqual(
+ gURLBar.searchMode,
+ {
+ engineName: engine.name,
+ entry: "topsites_newtab",
+ isPreview: false,
+ isGeneralPurposeEngine: false,
+ },
+ "The Urlbar is in search mode."
+ );
+ ok(
+ gURLBar.hasAttribute("searchmode"),
+ "The Urlbar has the searchmode attribute."
+ );
+ },
+});
+
+// test_newtab is not used here as this test requires two steps into the
+// content process with chrome process activity in-between.
+add_task(async function test_search_topsite_remove_engine() {
+ // Open about:newtab without using the default load listener
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false
+ );
+
+ // Specially wait for potentially preloaded browsers
+ let browser = tab.linkedBrowser;
+ await waitForPreloaded(browser);
+
+ // Add shared helpers to the content process
+ SpecialPowers.spawn(browser, [], addContentHelpers);
+
+ // Wait for React to render something
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ SpecialPowers.spawn(
+ browser,
+ [],
+ () => content.document.getElementById("root").children.length
+ ),
+ "Should render activity stream content"
+ );
+
+ await setDefaultTopSites();
+
+ let [topSiteAlias, numTopSites] = await SpecialPowers.spawn(
+ browser,
+ [],
+ async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".search-shortcut .title.pinned"),
+ "Wait for pinned search topsites"
+ );
+
+ const searchTopSites = content.document.querySelectorAll(".title.pinned");
+ ok(searchTopSites.length >= 1, "There should be at least one topsite");
+ return [searchTopSites[0].innerText.trim(), searchTopSites.length];
+ }
+ );
+
+ await Services.search.removeEngine(
+ await Services.search.getEngineByAlias(topSiteAlias)
+ );
+
+ registerCleanupFunction(() => {
+ Services.search.restoreDefaultEngines();
+ });
+
+ await SpecialPowers.spawn(
+ browser,
+ [numTopSites],
+ async originalNumTopSites => {
+ await ContentTaskUtils.waitForCondition(
+ () => !content.document.querySelector(".search-shortcut .title.pinned"),
+ "Wait for pinned search topsites"
+ );
+
+ const searchTopSites = content.document.querySelectorAll(".title.pinned");
+ is(
+ searchTopSites.length,
+ originalNumTopSites - 1,
+ "There should be one less search topsites"
+ );
+ }
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/newtab/test/browser/browser_trigger_listeners.js b/browser/components/newtab/test/browser/browser_trigger_listeners.js
new file mode 100644
index 0000000000..c7a502fdd0
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_trigger_listeners.js
@@ -0,0 +1,343 @@
+const { ASRouterTriggerListeners } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouterTriggerListeners.jsm"
+);
+
+const mockIdleService = {
+ _observers: new Set(),
+ _fireObservers(state) {
+ for (let observer of this._observers.values()) {
+ observer.observe(this, state, null);
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]),
+ idleTime: 1200000,
+ addIdleObserver(observer, time) {
+ this._observers.add(observer);
+ },
+ removeIdleObserver(observer, time) {
+ this._observers.delete(observer);
+ },
+};
+
+const sleepMs = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)); // eslint-disable-line mozilla/no-arbitrary-setTimeout
+
+const inChaosMode = !!parseInt(Services.env.get("MOZ_CHAOSMODE"), 16);
+
+add_setup(async function () {
+ // Runtime increases in chaos mode on Mac.
+ if (inChaosMode && AppConstants.platform === "macosx") {
+ requestLongerTimeout(2);
+ }
+
+ registerCleanupFunction(() => {
+ const trigger = ASRouterTriggerListeners.get("openURL");
+ trigger.uninit();
+ });
+});
+
+add_task(async function test_openURL_visit_counter() {
+ const trigger = ASRouterTriggerListeners.get("openURL");
+ const stub = sinon.stub();
+ trigger.uninit();
+
+ trigger.init(stub, ["example.com"]);
+
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("https://example.com/");
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("http://example.com/");
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("http://example.com/");
+
+ Assert.equal(stub.callCount, 3, "Stub called 3 times for example.com host");
+ Assert.equal(
+ stub.firstCall.args[1].context.visitsCount,
+ 1,
+ "First call should have count 1"
+ );
+ Assert.equal(
+ stub.thirdCall.args[1].context.visitsCount,
+ 2,
+ "Third call should have count 2 for http://example.com"
+ );
+});
+
+add_task(async function test_openURL_visit_counter_withPattern() {
+ const trigger = ASRouterTriggerListeners.get("openURL");
+ const stub = sinon.stub();
+ trigger.uninit();
+
+ // Match any valid URL
+ trigger.init(stub, [], ["*://*/*"]);
+
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("https://example.com/");
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("http://example.com/");
+ await waitForUrlLoad("about:blank");
+ await waitForUrlLoad("http://example.com/");
+
+ Assert.equal(stub.callCount, 3, "Stub called 3 times for example.com host");
+ Assert.equal(
+ stub.firstCall.args[1].context.visitsCount,
+ 1,
+ "First call should have count 1"
+ );
+ Assert.equal(
+ stub.thirdCall.args[1].context.visitsCount,
+ 2,
+ "Third call should have count 2 for http://example.com"
+ );
+});
+
+add_task(async function test_captivePortalLogin() {
+ const stub = sinon.stub();
+ const captivePortalTrigger =
+ ASRouterTriggerListeners.get("captivePortalLogin");
+
+ captivePortalTrigger.init(stub);
+
+ Services.obs.notifyObservers(this, "captive-portal-login-success", {});
+
+ Assert.ok(stub.called, "Called after login event");
+
+ captivePortalTrigger.uninit();
+
+ Services.obs.notifyObservers(this, "captive-portal-login-success", {});
+
+ Assert.equal(stub.callCount, 1, "Not called after uninit");
+});
+
+add_task(async function test_preferenceObserver() {
+ const stub = sinon.stub();
+ const poTrigger = ASRouterTriggerListeners.get("preferenceObserver");
+
+ poTrigger.uninit();
+
+ poTrigger.init(stub, ["foo.bar", "bar.foo"]);
+
+ Services.prefs.setStringPref("foo.bar", "foo.bar");
+
+ Assert.ok(stub.calledOnce, "Called for pref foo.bar");
+ Assert.deepEqual(
+ stub.firstCall.args[1],
+ {
+ id: "preferenceObserver",
+ param: { type: "foo.bar" },
+ },
+ "Called with expected arguments"
+ );
+
+ Services.prefs.setStringPref("bar.foo", "bar.foo");
+ Assert.ok(stub.calledTwice, "Called again for second pref.");
+ Services.prefs.clearUserPref("foo.bar");
+ Assert.ok(stub.calledThrice, "Called when clearing the pref as well.");
+
+ stub.resetHistory();
+ poTrigger.uninit();
+
+ Services.prefs.clearUserPref("bar.foo");
+ Assert.ok(stub.notCalled, "Not called after uninit");
+});
+
+add_task(async function test_nthTabClosed() {
+ const handlerStub = sinon.stub();
+ const tabClosedTrigger = ASRouterTriggerListeners.get("nthTabClosed");
+ tabClosedTrigger.uninit();
+ tabClosedTrigger.init(handlerStub);
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ BrowserTestUtils.removeTab(tab1);
+ Assert.ok(handlerStub.calledOnce, "Called once after first tab closed");
+
+ BrowserTestUtils.removeTab(tab2);
+ Assert.ok(handlerStub.calledTwice, "Called twice after second tab closed");
+
+ handlerStub.resetHistory();
+ tabClosedTrigger.uninit();
+
+ Assert.ok(handlerStub.notCalled, "Not called after uninit");
+});
+
+add_task(async function test_cookieBannerDetected() {
+ const handlerStub = sinon.stub();
+ const bannerDetectedTrigger = ASRouterTriggerListeners.get(
+ "cookieBannerDetected"
+ );
+ bannerDetectedTrigger.uninit();
+ bannerDetectedTrigger.init(handlerStub);
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ let eventWait = BrowserTestUtils.waitForEvent(win, "cookiebannerdetected");
+ win.dispatchEvent(new Event("cookiebannerdetected"));
+ await eventWait;
+ let closeWindow = BrowserTestUtils.closeWindow(win);
+
+ Assert.ok(
+ handlerStub.called,
+ "Called after `cookiebannerdetected` event fires"
+ );
+
+ handlerStub.resetHistory();
+ bannerDetectedTrigger.uninit();
+
+ Assert.ok(handlerStub.notCalled, "Not called after uninit");
+ await closeWindow;
+});
+
+function getIdleTriggerMock() {
+ const idleTrigger = ASRouterTriggerListeners.get("activityAfterIdle");
+ idleTrigger.uninit();
+ const sandbox = sinon.createSandbox();
+ const handlerStub = sandbox.stub();
+ sandbox.stub(idleTrigger, "_triggerDelay").value(0);
+ sandbox.stub(idleTrigger, "_wakeDelay").value(30);
+ sandbox.stub(idleTrigger, "_idleService").value(mockIdleService);
+ let restored = false;
+ const restore = () => {
+ if (restored) return;
+ restored = true;
+ idleTrigger.uninit();
+ sandbox.restore();
+ };
+ registerCleanupFunction(restore);
+ idleTrigger.init(handlerStub);
+ return { idleTrigger, handlerStub, restore };
+}
+
+// Test that the trigger fires under normal conditions.
+add_task(async function test_activityAfterIdle() {
+ const { handlerStub, restore } = getIdleTriggerMock();
+ let firedOnActive = new Promise(resolve =>
+ handlerStub.callsFake(() => resolve(true))
+ );
+ mockIdleService._fireObservers("idle");
+ await TestUtils.waitForTick();
+ ok(handlerStub.notCalled, "Not called when idle");
+ mockIdleService._fireObservers("active");
+ ok(await firedOnActive, "Called once when active after idle");
+ restore();
+});
+
+// Test that the trigger does not fire when the active window is private.
+add_task(async function test_activityAfterIdlePrivateWindow() {
+ const { handlerStub, restore } = getIdleTriggerMock();
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ ok(PrivateBrowsingUtils.isWindowPrivate(privateWin), "Window is private");
+ await TestUtils.waitForTick();
+ mockIdleService._fireObservers("idle");
+ await TestUtils.waitForTick();
+ mockIdleService._fireObservers("active");
+ await TestUtils.waitForTick();
+ ok(handlerStub.notCalled, "Not called when active window is private");
+ await BrowserTestUtils.closeWindow(privateWin);
+ restore();
+});
+
+// Test that the trigger does not fire when the window is minimized, but does
+// fire after the window is restored.
+add_task(async function test_activityAfterIdleHiddenWindow() {
+ const { handlerStub, restore } = getIdleTriggerMock();
+ let firedOnRestore = new Promise(resolve =>
+ handlerStub.callsFake(() => resolve(true))
+ );
+ window.minimize();
+ await BrowserTestUtils.waitForCondition(
+ () => window.windowState === window.STATE_MINIMIZED,
+ "Window should be minimized"
+ );
+ mockIdleService._fireObservers("idle");
+ await TestUtils.waitForTick();
+ mockIdleService._fireObservers("active");
+ await TestUtils.waitForTick();
+ ok(handlerStub.notCalled, "Not called when window is minimized");
+ window.restore();
+ ok(await firedOnRestore, "Called once after restoring minimized window");
+ restore();
+});
+
+// Test that the trigger does not fire immediately after waking from sleep.
+add_task(async function test_activityAfterIdleWake() {
+ const { handlerStub, restore } = getIdleTriggerMock();
+ let firedAfterWake = new Promise(resolve =>
+ handlerStub.callsFake(() => resolve(true))
+ );
+ mockIdleService._fireObservers("wake_notification");
+ mockIdleService._fireObservers("idle");
+ await sleepMs(1);
+ mockIdleService._fireObservers("active");
+ await sleepMs(inChaosMode ? 32 : 300);
+ ok(handlerStub.notCalled, "Not called immediately after waking from sleep");
+
+ mockIdleService._fireObservers("idle");
+ await TestUtils.waitForTick();
+ mockIdleService._fireObservers("active");
+ ok(
+ await firedAfterWake,
+ "Called once after waiting for wake delay before firing idle"
+ );
+ restore();
+});
+
+add_task(async function test_formAutofillTrigger() {
+ const sandbox = sinon.createSandbox();
+ const handlerStub = sandbox.stub();
+ const formAutofillTrigger = ASRouterTriggerListeners.get("formAutofill");
+ sandbox.stub(formAutofillTrigger, "_triggerDelay").value(0);
+ formAutofillTrigger.uninit();
+ formAutofillTrigger.init(handlerStub);
+
+ function notifyCreditCardSaved() {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: { sourceSync: false, collectionName: "creditCards" },
+ },
+ formAutofillTrigger._topic,
+ "add"
+ );
+ }
+
+ // Saving credit cards for autofill currently fails for some hardware
+ // configurations, so mock the event instead of really adding a card.
+ notifyCreditCardSaved();
+ await sleepMs(1);
+ Assert.ok(handlerStub.called, "Called after event");
+
+ // Test that the trigger doesn't fire when the credit card manager is open.
+ handlerStub.resetHistory();
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:preferences#privacy" },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () =>
+ (
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("#creditCardAutofill button"),
+ "Waiting for credit card manager button"
+ )
+ )?.click()
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => browser.contentWindow?.gSubDialog?.dialogs.length
+ );
+ notifyCreditCardSaved();
+ await sleepMs(1);
+ Assert.ok(
+ handlerStub.notCalled,
+ "Not called when credit card manager is open"
+ );
+ }
+ );
+
+ formAutofillTrigger.uninit();
+ handlerStub.resetHistory();
+ notifyCreditCardSaved();
+ await sleepMs(1);
+ Assert.ok(handlerStub.notCalled, "Not called after uninit");
+
+ sandbox.restore();
+ formAutofillTrigger.uninit();
+});
diff --git a/browser/components/newtab/test/browser/browser_trigger_messagesLoaded.js b/browser/components/newtab/test/browser/browser_trigger_messagesLoaded.js
new file mode 100644
index 0000000000..8168715289
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_trigger_messagesLoaded.js
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { RemoteSettingsExperimentLoader } = ChromeUtils.importESModule(
+ "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
+);
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+const { ExperimentFakes, ExperimentTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const client = RemoteSettings("nimbus-desktop-experiments");
+
+const TEST_MESSAGE_CONTENT = {
+ id: "ON_LOAD_TEST_MESSAGE",
+ template: "cfr_doorhanger",
+ content: {
+ bucket_id: "ON_LOAD_TEST_MESSAGE",
+ anchor_id: "PanelUI-menu-button",
+ layout: "icon_and_message",
+ icon: "chrome://browser/content/cfr-lightning.svg",
+ icon_dark_theme: "chrome://browser/content/cfr-lightning-dark.svg",
+ icon_class: "cfr-doorhanger-small-icon",
+ heading_text: "Heading",
+ text: "Text",
+ buttons: {
+ primary: {
+ label: { value: "Primary CTA", attributes: { accesskey: "P" } },
+ action: { navigate: true },
+ },
+ secondary: [
+ {
+ label: { value: "Secondary CTA", attributes: { accesskey: "S" } },
+ action: { type: "CANCEL" },
+ },
+ ],
+ },
+ skip_address_bar_notifier: true,
+ },
+ targeting: "true",
+ trigger: { id: "messagesLoaded" },
+};
+
+add_task(async function test_messagesLoaded_reach_experiment() {
+ const sandbox = sinon.createSandbox();
+ const sendTriggerSpy = sandbox.spy(ASRouter, "sendTriggerMessage");
+ const routeSpy = sandbox.spy(ASRouter, "routeCFRMessage");
+ const reachSpy = sandbox.spy(ASRouter, "_recordReachEvent");
+ const triggerMatch = sandbox.match({ id: "messagesLoaded" });
+ const featureId = "cfr";
+ const recipe = ExperimentFakes.recipe(
+ `messages_loaded_test_${Services.uuid
+ .generateUUID()
+ .toString()
+ .slice(1, -1)}`,
+ {
+ id: `messages-loaded-test`,
+ bucketConfig: {
+ count: 100,
+ start: 0,
+ total: 100,
+ namespace: "mochitest",
+ randomizationUnit: "normandy_id",
+ },
+ branches: [
+ {
+ slug: "control",
+ ratio: 1,
+ features: [
+ {
+ featureId,
+ value: { ...TEST_MESSAGE_CONTENT, id: "messages-loaded-test-1" },
+ },
+ ],
+ },
+ {
+ slug: "treatment",
+ ratio: 1,
+ features: [
+ {
+ featureId,
+ value: { ...TEST_MESSAGE_CONTENT, id: "messages-loaded-test-2" },
+ },
+ ],
+ },
+ ],
+ }
+ );
+ Assert.ok(
+ await ExperimentTestUtils.validateExperiment(recipe),
+ "Valid recipe"
+ );
+
+ await client.db.importChanges({}, Date.now(), [recipe], { clear: true });
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["app.shield.optoutstudies.enabled", true],
+ ["datareporting.healthreport.uploadEnabled", true],
+ [
+ "browser.newtabpage.activity-stream.asrouter.providers.messaging-experiments",
+ `{"id":"messaging-experiments","enabled":true,"type":"remote-experiments","updateCycleInMs":0}`,
+ ],
+ ],
+ });
+ await RemoteSettingsExperimentLoader.updateRecipes();
+ await BrowserTestUtils.waitForCondition(
+ () => ExperimentAPI.getExperiment({ featureId }),
+ "ExperimentAPI should return an experiment"
+ );
+
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders();
+
+ const filterFn = m =>
+ ["messages-loaded-test-1", "messages-loaded-test-2"].includes(m?.id);
+ await BrowserTestUtils.waitForCondition(
+ () => ASRouter.state.messages.filter(filterFn).length > 1,
+ "Should load the test messages"
+ );
+ Assert.ok(sendTriggerSpy.calledWith(triggerMatch, true), "Trigger fired");
+ Assert.ok(
+ routeSpy.calledWith(
+ sandbox.match(filterFn),
+ gBrowser.selectedBrowser,
+ triggerMatch
+ ),
+ "Trigger routed to the correct message"
+ );
+ Assert.ok(
+ reachSpy.calledWith(sandbox.match(filterFn)),
+ "Trigger recorded a reach event"
+ );
+ Assert.ok(
+ ASRouter.state.messages.find(m => filterFn(m) && m.forReachEvent)
+ ?.forReachEvent.sent,
+ "Reach message will not be sent again"
+ );
+
+ sandbox.restore();
+ await client.db.clear();
+ await SpecialPowers.popPrefEnv();
+ await ASRouter._updateMessageProviders();
+});
diff --git a/browser/components/newtab/test/browser/ds_layout.json b/browser/components/newtab/test/browser/ds_layout.json
new file mode 100644
index 0000000000..b9c7e6b4ba
--- /dev/null
+++ b/browser/components/newtab/test/browser/ds_layout.json
@@ -0,0 +1,90 @@
+{
+ "spocs": {
+ "url": ""
+ },
+ "layout": [
+ {
+ "width": 12,
+ "components": [
+ {
+ "type": "TopSites",
+ "header": {
+ "title": "Top Sites"
+ },
+ "properties": null
+ },
+ {
+ "type": "Message",
+ "header": {
+ "title": "Recommended by Pocket",
+ "subtitle": "",
+ "link_text": "How it works",
+ "link_url": "https://getpocket.com/firefox/new_tab_learn_more",
+
+ "icon": "chrome://global/skin/icons/pocket.svg"
+ },
+ "properties": null,
+ "styles": {
+ ".ds-message": "margin-bottom: -20px"
+ }
+ },
+ {
+ "type": "CardGrid",
+ "properties": {
+ "items": 3
+ },
+ "header": {
+ "title": ""
+ },
+ "feed": {
+ "embed_reference": null,
+ "url": "https://example.com/browser/browser/components/newtab/test/browser/topstories.json"
+ },
+ "spocs": {
+ "probability": 1,
+ "positions": [
+ {
+ "index": 2
+ }
+ ]
+ }
+ },
+ {
+ "type": "Navigation",
+ "properties": {
+ "alignment": "left-align",
+ "links": [
+ {
+ "name": "Must Reads",
+ "url": "https://getpocket.com/explore/must-reads?src=fx_new_tab"
+ },
+ {
+ "name": "Productivity",
+ "url": "https://getpocket.com/explore/productivity?src=fx_new_tab"
+ },
+ {
+ "name": "Health",
+ "url": "https://getpocket.com/explore/health?src=fx_new_tab"
+ },
+ {
+ "name": "Finance",
+ "url": "https://getpocket.com/explore/finance?src=fx_new_tab"
+ },
+ {
+ "name": "Technology",
+ "url": "https://getpocket.com/explore/technology?src=fx_new_tab"
+ },
+ {
+ "name": "More Recommendations ›",
+ "url": "https://getpocket.com/explore/trending?src=fx_new_tab"
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ],
+ "feeds": {},
+ "error": 0,
+ "status": 1
+}
diff --git a/browser/components/newtab/test/browser/file_pdf.PDF b/browser/components/newtab/test/browser/file_pdf.PDF
new file mode 100644
index 0000000000..593558f9a4
--- /dev/null
+++ b/browser/components/newtab/test/browser/file_pdf.PDF
@@ -0,0 +1,12 @@
+%PDF-1.0
+1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 3 3]>>endobj
+xref
+0 4
+0000000000 65535 f
+0000000010 00000 n
+0000000053 00000 n
+0000000102 00000 n
+trailer<</Size 4/Root 1 0 R>>
+startxref
+149
+%EOF \ No newline at end of file
diff --git a/browser/components/newtab/test/browser/head.js b/browser/components/newtab/test/browser/head.js
new file mode 100644
index 0000000000..cc0239e148
--- /dev/null
+++ b/browser/components/newtab/test/browser/head.js
@@ -0,0 +1,392 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ObjectUtils",
+ "resource://gre/modules/ObjectUtils.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "QueryCache",
+ "resource://activity-stream/lib/ASRouterTargeting.jsm"
+);
+// eslint-disable-next-line no-unused-vars
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+// We import sinon here to make it available across all mochitest test files
+// eslint-disable-next-line no-unused-vars
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+// Set the content pref to make it available across tests
+const ABOUT_WELCOME_OVERRIDE_CONTENT_PREF = "browser.aboutwelcome.screens";
+// Test differently for windows 7 as theme screens are removed.
+// eslint-disable-next-line no-unused-vars
+const win7Content = AppConstants.isPlatformAndVersionAtMost("win", "6.1");
+
+function popPrefs() {
+ return SpecialPowers.popPrefEnv();
+}
+function pushPrefs(...prefs) {
+ return SpecialPowers.pushPrefEnv({ set: prefs });
+}
+// eslint-disable-next-line no-unused-vars
+async function getAboutWelcomeParent(browser) {
+ let windowGlobalParent = browser.browsingContext.currentWindowGlobal;
+ return windowGlobalParent.getActor("AboutWelcome");
+}
+// eslint-disable-next-line no-unused-vars
+async function setAboutWelcomeMultiStage(value = "") {
+ return pushPrefs([ABOUT_WELCOME_OVERRIDE_CONTENT_PREF, value]);
+}
+
+/**
+ * Setup functions to test welcome UI
+ */
+// eslint-disable-next-line no-unused-vars
+async function test_screen_content(
+ browser,
+ experiment,
+ expectedSelectors = [],
+ unexpectedSelectors = []
+) {
+ await ContentTask.spawn(
+ browser,
+ { expectedSelectors, experiment, unexpectedSelectors },
+ async ({
+ expectedSelectors: expected,
+ experiment: experimentName,
+ unexpectedSelectors: unexpected,
+ }) => {
+ for (let selector of expected) {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(selector),
+ `Should render ${selector} in ${experimentName}`
+ );
+ }
+ for (let selector of unexpected) {
+ ok(
+ !content.document.querySelector(selector),
+ `Should not render ${selector} in ${experimentName}`
+ );
+ }
+
+ if (experimentName === "home") {
+ Assert.equal(
+ content.document.location.href,
+ "about:home",
+ "Navigated to about:home"
+ );
+ } else {
+ Assert.equal(
+ content.document.location.href,
+ "about:welcome",
+ "Navigated to a welcome screen"
+ );
+ }
+ }
+ );
+}
+
+// eslint-disable-next-line no-unused-vars
+async function test_element_styles(
+ browser,
+ elementSelector,
+ expectedStyles = {},
+ unexpectedStyles = {}
+) {
+ await ContentTask.spawn(
+ browser,
+ [elementSelector, expectedStyles, unexpectedStyles],
+ async ([selector, expected, unexpected]) => {
+ const element = await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector(selector)
+ );
+ const computedStyles = content.window.getComputedStyle(element);
+ Object.entries(expected).forEach(([attr, val]) =>
+ is(
+ computedStyles[attr],
+ val,
+ `${selector} should have computed ${attr} of ${val}`
+ )
+ );
+ Object.entries(unexpected).forEach(([attr, val]) =>
+ isnot(
+ computedStyles[attr],
+ val,
+ `${selector} should not have computed ${attr} of ${val}`
+ )
+ );
+ }
+ );
+}
+
+// eslint-disable-next-line no-unused-vars
+async function onButtonClick(browser, elementId) {
+ await ContentTask.spawn(
+ browser,
+ { elementId },
+ async ({ elementId: buttonId }) => {
+ let button = await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(buttonId),
+ buttonId
+ );
+ button.click();
+ }
+ );
+}
+
+// Toggle the feed off and on as a workaround to read the new prefs.
+async function toggleTopsitesPref() {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.feeds.system.topsites",
+ false,
+ ]);
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.feeds.system.topsites",
+ true,
+ ]);
+}
+
+// eslint-disable-next-line no-unused-vars
+async function setDefaultTopSites() {
+ // The pref for TopSites is empty by default.
+ await pushPrefs([
+ "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/",
+ ]);
+ await toggleTopsitesPref();
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
+ true,
+ ]);
+}
+
+// eslint-disable-next-line no-unused-vars
+async function setTestTopSites() {
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
+ false,
+ ]);
+ // The pref for TopSites is empty by default.
+ // Using a topsite with example.com allows us to open the topsite without a network request.
+ await pushPrefs([
+ "browser.newtabpage.activity-stream.default.sites",
+ "https://example.com/",
+ ]);
+ await toggleTopsitesPref();
+}
+
+// eslint-disable-next-line no-unused-vars
+async function setAboutWelcomePref(value) {
+ return pushPrefs(["browser.aboutwelcome.enabled", value]);
+}
+
+// eslint-disable-next-line no-unused-vars
+async function openMRAboutWelcome() {
+ await setAboutWelcomePref(true); // NB: Calls pushPrefs
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:welcome",
+ true
+ );
+
+ return {
+ browser: tab.linkedBrowser,
+ cleanup: async () => {
+ BrowserTestUtils.removeTab(tab);
+ await popPrefs(); // for setAboutWelcomePref()
+ },
+ };
+}
+
+// eslint-disable-next-line no-unused-vars
+async function clearHistoryAndBookmarks() {
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+ QueryCache.expireAll();
+}
+
+/**
+ * Helper to wait for potentially preloaded browsers to "load" where a preloaded
+ * page has already loaded and won't trigger "load", and a "load"ed page might
+ * not necessarily have had all its javascript/render logic executed.
+ */
+async function waitForPreloaded(browser) {
+ let readyState = await ContentTask.spawn(
+ browser,
+ null,
+ () => content.document.readyState
+ );
+ if (readyState !== "complete") {
+ await BrowserTestUtils.browserLoaded(browser);
+ }
+}
+
+/**
+ * Helper function to navigate and wait for page to load
+ * https://searchfox.org/mozilla-central/rev/b2716c233e9b4398fc5923cbe150e7f83c7c6c5b/testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm#383
+ */
+// eslint-disable-next-line no-unused-vars
+async function waitForUrlLoad(url) {
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser, false, url);
+}
+
+/**
+ * Helper to force the HighlightsFeed to update.
+ */
+function refreshHighlightsFeed() {
+ // Toggling the pref will clear the feed cache and force a places query.
+ Services.prefs.setBoolPref(
+ "browser.newtabpage.activity-stream.feeds.section.highlights",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "browser.newtabpage.activity-stream.feeds.section.highlights",
+ true
+ );
+}
+
+/**
+ * Helper to populate the Highlights section with bookmark cards.
+ * @param count Number of items to add.
+ */
+// eslint-disable-next-line no-unused-vars
+async function addHighlightsBookmarks(count) {
+ const bookmarks = new Array(count).fill(null).map((entry, i) => ({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "foo",
+ url: `https://mozilla${i}.com/nowNew`,
+ }));
+
+ for (let placeInfo of bookmarks) {
+ await PlacesUtils.bookmarks.insert(placeInfo);
+ // Bookmarks need at least one visit to show up as highlights.
+ await PlacesTestUtils.addVisits(placeInfo.url);
+ }
+
+ // Force HighlightsFeed to make a request for the new items.
+ refreshHighlightsFeed();
+}
+
+/**
+ * Helper to add various helpers to the content process by injecting variables
+ * and functions to the `content` global.
+ */
+function addContentHelpers() {
+ const { document } = content;
+ Object.assign(content, {
+ /**
+ * Click the context menu button for an item and get its options list.
+ *
+ * @param selector {String} Selector to get an item (e.g., top site, card)
+ * @return {Array} The nodes for the options.
+ */
+ async openContextMenuAndGetOptions(selector) {
+ const item = document.querySelector(selector);
+ const contextButton = item.querySelector(".context-menu-button");
+ contextButton.click();
+ // Gives fluent-dom the time to render strings
+ await new Promise(r => content.requestAnimationFrame(r));
+
+ const contextMenu = item.querySelector(".context-menu");
+ const contextMenuList = contextMenu.querySelector(".context-menu-list");
+ return [...contextMenuList.getElementsByClassName("context-menu-item")];
+ },
+ });
+}
+
+/**
+ * Helper to run Activity Stream about:newtab test tasks in content.
+ *
+ * @param testInfo {Function|Object}
+ * {Function} This parameter will be used as if the function were called with
+ * an Object with this parameter as "test" key's value.
+ * {Object} The following keys are expected:
+ * before {Function} Optional. Runs before and returns an arg for "test"
+ * test {Function} The test to run in the about:newtab content task taking
+ * an arg from "before" and returns a result to "after"
+ * after {Function} Optional. Runs after and with the result of "test"
+ * @param browserURL {optional String}
+ * {String} This parameter is used to explicitly specify URL opened in new tab
+ */
+// eslint-disable-next-line no-unused-vars
+function test_newtab(testInfo, browserURL = "about:newtab") {
+ // Extract any test parts or default to just the single content task
+ let { before, test: contentTask, after } = testInfo;
+ if (!before) {
+ before = () => ({});
+ }
+ if (!contentTask) {
+ contentTask = testInfo;
+ }
+ if (!after) {
+ after = () => {};
+ }
+
+ // Helper to push prefs for just this test and pop them when done
+ let needPopPrefs = false;
+ let scopedPushPrefs = async (...args) => {
+ needPopPrefs = true;
+ await pushPrefs(...args);
+ };
+ let scopedPopPrefs = async () => {
+ if (needPopPrefs) {
+ await popPrefs();
+ }
+ };
+
+ // Make the test task with optional before/after and content task to run in a
+ // new tab that opens and closes.
+ let testTask = async () => {
+ // Open about:newtab without using the default load listener
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ browserURL,
+ false
+ );
+
+ // Specially wait for potentially preloaded browsers
+ let browser = tab.linkedBrowser;
+ await waitForPreloaded(browser);
+
+ // Add shared helpers to the content process
+ SpecialPowers.spawn(browser, [], addContentHelpers);
+
+ // Wait for React to render something
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ SpecialPowers.spawn(
+ browser,
+ [],
+ () => content.document.getElementById("root").children.length
+ ),
+ "Should render activity stream content"
+ );
+
+ // Chain together before -> contentTask -> after data passing
+ try {
+ let contentArg = await before({ pushPrefs: scopedPushPrefs, tab });
+ let contentResult = await SpecialPowers.spawn(
+ browser,
+ [contentArg],
+ contentTask
+ );
+ await after(contentResult);
+ } finally {
+ // Clean up for next tests
+ await scopedPopPrefs();
+ BrowserTestUtils.removeTab(tab);
+ }
+ };
+
+ // Copy the name of the content task to identify the test
+ Object.defineProperty(testTask, "name", { value: contentTask.name });
+ add_task(testTask);
+}
diff --git a/browser/components/newtab/test/browser/red_page.html b/browser/components/newtab/test/browser/red_page.html
new file mode 100644
index 0000000000..733a1f0d4a
--- /dev/null
+++ b/browser/components/newtab/test/browser/red_page.html
@@ -0,0 +1,6 @@
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body style="background-color: red" />
+</html>
diff --git a/browser/components/newtab/test/browser/redirect_to.sjs b/browser/components/newtab/test/browser/redirect_to.sjs
new file mode 100644
index 0000000000..b52ebdc63e
--- /dev/null
+++ b/browser/components/newtab/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
+ const redirectUrl = request.queryString;
+ response.setStatusLine(request.httpVersion, "302", "Found");
+ response.setHeader("Location", redirectUrl, false);
+}
diff --git a/browser/components/newtab/test/browser/snippet.json b/browser/components/newtab/test/browser/snippet.json
new file mode 100644
index 0000000000..ae6a1a4bff
--- /dev/null
+++ b/browser/components/newtab/test/browser/snippet.json
@@ -0,0 +1,46 @@
+{
+ "messages": [
+ {
+ "weight": 50,
+ "id": "10533",
+ "template": "simple_snippet",
+ "template_version": "1.0.0",
+ "content": {
+ "icon": "",
+ "text": "On January 30th Nightly will introduce dedicated profiles, making it simpler to run different installations of Firefox side by side. <link0> Learn what this means for you</link0>.",
+ "tall": false,
+ "do_not_autoblock": false,
+ "links": {
+ "link0": {
+ "url": "https://example.com/"
+ }
+ }
+ },
+ "campaign": "nightly-profile-management",
+ "targeting": "true",
+ "provider_url": "https://snippets.cdn.mozilla.net/6/Firefox/66.0a1/20190122215349/Darwin_x86_64-gcc3/en-US/default/Darwin%2018.0.0/default/default/",
+ "provider": "snippets"
+ },
+ {
+ "weight": 50,
+ "id": "10534",
+ "template": "simple_snippet",
+ "template_version": "1.0.0",
+ "content": {
+ "icon": "",
+ "text": "On January 30th Nightly will introduce dedicated profiles, making it simpler to run different installations of Firefox side by side. <link0> Learn what this means for you</link0>.",
+ "tall": false,
+ "do_not_autoblock": false,
+ "links": {
+ "link0": {
+ "url": "https://example.com/"
+ }
+ }
+ },
+ "campaign": "nightly-profile-management",
+ "targeting": "true",
+ "provider_url": "https://snippets.cdn.mozilla.net/6/Firefox/66.0a1/20190122215349/Darwin_x86_64-gcc3/en-US/default/Darwin%2018.0.0/default/default/",
+ "provider": "snippets"
+ }
+ ]
+}
diff --git a/browser/components/newtab/test/browser/snippet_below_search_test.json b/browser/components/newtab/test/browser/snippet_below_search_test.json
new file mode 100644
index 0000000000..935ef9d6c2
--- /dev/null
+++ b/browser/components/newtab/test/browser/snippet_below_search_test.json
@@ -0,0 +1,20 @@
+{
+ "messages": [
+ {
+ "id": "SIMPLE_BELOW_SEARCH_TEST_1",
+ "template": "simple_below_search_snippet",
+ "content": {
+ "icon": "chrome://branding/content/icon64.png",
+ "icon_dark_theme": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQfjBQ8QDifrKGc/AAABf0lEQVQoz4WRO08UUQCFvztzd1AgG9jRgGwkhEoMIYGSygYt+A00tpZGY0jYxAJKEwkNjX9AK2xACx4dhFiQQCiMMRr2kYXdnQcz7L0z91qAMVac6hTfSU7OgVsk/prtyfSNfRb7ge2cd7dmVucP/wM2lwqVqoyICahRx9Nz71+8AnAAvlTct+dSYDBYcgJ+Fj68XFu/AfamnIoWFoHFYrAUuYMSn55/fAIOxIs1t4MhQpNxRYsUD0ld7r8DCfZph4QecrqkhCREgMLSeISQkAy0UBgE0CYgIkeRA9HdsCQhpEGCxichpItHigEcPH4XJLRbTf8STY0iiiuu60Ifxexx04F0N+aCgJCAhPQmD/cp/RC5A79WvUyhUHSIidAIoESv9VfAhW9n8+XqTCoyMsz1cviMMrGz9BrjAuboYHZajyXCInEocI8yvccbC+0muABanR4/tONjQz3DzgNKtj9sfv66XD9B/3tT9g/akb7h0bJwzxqqmlRHLr4rLPwBlYWoYj77l2AAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTktMDUtMTVUMTY6MTQ6MzkrMDA6MDD5/4XBAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE5LTA1LTE1VDE2OjE0OjM5KzAwOjAwiKI9fQAAAABJRU5ErkJggg==",
+ "text": "Securely store passwords, bookmarks, and more with a Firefox Account. <syncLink>Sign up</syncLink>",
+ "links": {
+ "syncLink": {
+ "url": "https://www.mozilla.org/en-US/firefox/accounts"
+ }
+ },
+ "block_button_text": "Block"
+ },
+ "targeting": "true"
+ }
+ ]
+}
diff --git a/browser/components/newtab/test/browser/snippet_simple_test.json b/browser/components/newtab/test/browser/snippet_simple_test.json
new file mode 100644
index 0000000000..585e78f8fd
--- /dev/null
+++ b/browser/components/newtab/test/browser/snippet_simple_test.json
@@ -0,0 +1,24 @@
+{
+ "messages": [
+ {
+ "id": "SIMPLE_TEST_1",
+ "template": "simple_snippet",
+ "campaign": "test_campaign_blocking",
+ "content": {
+ "icon": "chrome://branding/content/icon64.png",
+ "icon_dark_theme": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQfjBQ8QDifrKGc/AAABf0lEQVQoz4WRO08UUQCFvztzd1AgG9jRgGwkhEoMIYGSygYt+A00tpZGY0jYxAJKEwkNjX9AK2xACx4dhFiQQCiMMRr2kYXdnQcz7L0z91qAMVac6hTfSU7OgVsk/prtyfSNfRb7ge2cd7dmVucP/wM2lwqVqoyICahRx9Nz71+8AnAAvlTct+dSYDBYcgJ+Fj68XFu/AfamnIoWFoHFYrAUuYMSn55/fAIOxIs1t4MhQpNxRYsUD0ld7r8DCfZph4QecrqkhCREgMLSeISQkAy0UBgE0CYgIkeRA9HdsCQhpEGCxichpItHigEcPH4XJLRbTf8STY0iiiuu60Ifxexx04F0N+aCgJCAhPQmD/cp/RC5A79WvUyhUHSIidAIoESv9VfAhW9n8+XqTCoyMsz1cviMMrGz9BrjAuboYHZajyXCInEocI8yvccbC+0muABanR4/tONjQz3DzgNKtj9sfv66XD9B/3tT9g/akb7h0bJwzxqqmlRHLr4rLPwBlYWoYj77l2AAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTktMDUtMTVUMTY6MTQ6MzkrMDA6MDD5/4XBAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE5LTA1LTE1VDE2OjE0OjM5KzAwOjAwiKI9fQAAAABJRU5ErkJggg==",
+ "title": "Firefox Account!",
+ "title_icon": "chrome://branding/content/icon16.png",
+ "title_icon_dark_theme": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQfjBQ8QDifrKGc/AAABf0lEQVQoz4WRO08UUQCFvztzd1AgG9jRgGwkhEoMIYGSygYt+A00tpZGY0jYxAJKEwkNjX9AK2xACx4dhFiQQCiMMRr2kYXdnQcz7L0z91qAMVac6hTfSU7OgVsk/prtyfSNfRb7ge2cd7dmVucP/wM2lwqVqoyICahRx9Nz71+8AnAAvlTct+dSYDBYcgJ+Fj68XFu/AfamnIoWFoHFYrAUuYMSn55/fAIOxIs1t4MhQpNxRYsUD0ld7r8DCfZph4QecrqkhCREgMLSeISQkAy0UBgE0CYgIkeRA9HdsCQhpEGCxichpItHigEcPH4XJLRbTf8STY0iiiuu60Ifxexx04F0N+aCgJCAhPQmD/cp/RC5A79WvUyhUHSIidAIoESv9VfAhW9n8+XqTCoyMsz1cviMMrGz9BrjAuboYHZajyXCInEocI8yvccbC+0muABanR4/tONjQz3DzgNKtj9sfv66XD9B/3tT9g/akb7h0bJwzxqqmlRHLr4rLPwBlYWoYj77l2AAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTktMDUtMTVUMTY6MTQ6MzkrMDA6MDD5/4XBAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE5LTA1LTE1VDE2OjE0OjM5KzAwOjAwiKI9fQAAAABJRU5ErkJggg==",
+ "text": "<syncLink>Sync it, link it, take it with you</syncLink>. All this and more with a Firefox Account.",
+ "links": {
+ "syncLink": {
+ "url": "https://www.mozilla.org/en-US/firefox/accounts"
+ }
+ },
+ "block_button_text": "Block"
+ },
+ "targeting": "true"
+ }
+ ]
+}
diff --git a/browser/components/newtab/test/browser/topstories.json b/browser/components/newtab/test/browser/topstories.json
new file mode 100644
index 0000000000..7d65fcb0e1
--- /dev/null
+++ b/browser/components/newtab/test/browser/topstories.json
@@ -0,0 +1,53 @@
+{
+ "status": 1,
+ "settings": {
+ "spocsPerNewTabs": 0.5,
+ "domainAffinityParameterSets": {
+ "default": {
+ "recencyFactor": 0.5,
+ "frequencyFactor": 0.5,
+ "combinedDomainFactor": 0.5,
+ "perfectFrequencyVisits": 10,
+ "perfectCombinedDomainScore": 2,
+ "multiDomainBoost": 0,
+ "itemScoreFactor": 1
+ },
+ "fully-personalized": {
+ "recencyFactor": 0.5,
+ "frequencyFactor": 0.5,
+ "combinedDomainFactor": 0.5,
+ "perfectFrequencyVisits": 10,
+ "perfectCombinedDomainScore": 2,
+ "itemScoreFactor": 0.01,
+ "multiDomainBoost": 0
+ }
+ },
+ "timeSegments": [
+ { "id": "week", "startTime": 604800, "endTime": 0, "weightPosition": 1 },
+ {
+ "id": "month",
+ "startTime": 2592000,
+ "endTime": 604800,
+ "weightPosition": 0.5
+ }
+ ],
+ "recsExpireTime": 5400,
+ "version": "2c2aa06dac65ddb647d8902aaa60263c8e119ff2"
+ },
+ "spocs": [],
+ "recommendations": [
+ {
+ "id": 53093,
+ "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAA/UlEQVR4nO3RMQ0AMAzAsPIn3d5DsBw2gkiZJWV+B/AyJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQmAP4K6zWNUjE4wAAAABJRU5ErkJggg==",
+ "domain": "bbc.com",
+ "title": "Why vegan junk food may be even worse for your health",
+ "excerpt": "While we might switch to a plant-based diet with the best intentions, the unseen risks of vegan fast foods might not show up for years.",
+ "image_src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAA/UlEQVR4nO3RMQ0AMAzAsPIn3d5DsBw2gkiZJWV+B/AyJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQmAP4K6zWNUjE4wAAAABJRU5ErkJggg==",
+ "published_timestamp": "1580277600",
+ "engagement": "",
+ "parameter_set": "default",
+ "domain_affinities": {},
+ "item_score": 1
+ }
+ ]
+}
diff --git a/browser/components/newtab/test/schemas/pings.js b/browser/components/newtab/test/schemas/pings.js
new file mode 100644
index 0000000000..e655121447
--- /dev/null
+++ b/browser/components/newtab/test/schemas/pings.js
@@ -0,0 +1,304 @@
+import {
+ CONTENT_MESSAGE_TYPE,
+ MAIN_MESSAGE_TYPE,
+} from "common/Actions.sys.mjs";
+import Joi from "joi-browser";
+
+export const baseKeys = {
+ // client_id will be set by PingCentre if it doesn't exist.
+ client_id: Joi.string().optional(),
+ addon_version: Joi.string().required(),
+ locale: Joi.string().required(),
+ session_id: Joi.string(),
+ page: Joi.valid([
+ "about:home",
+ "about:newtab",
+ "about:welcome",
+ "both",
+ "unknown",
+ ]),
+ user_prefs: Joi.number().integer().required(),
+};
+
+export const BasePing = Joi.object()
+ .keys(baseKeys)
+ .options({ allowUnknown: true });
+
+export const eventsTelemetryExtraKeys = Joi.object()
+ .keys({
+ session_id: baseKeys.session_id.required(),
+ page: baseKeys.page.required(),
+ addon_version: baseKeys.addon_version.required(),
+ user_prefs: baseKeys.user_prefs.required(),
+ action_position: Joi.string().optional(),
+ })
+ .options({ allowUnknown: false });
+
+export const UserEventPing = Joi.object().keys(
+ Object.assign({}, baseKeys, {
+ session_id: baseKeys.session_id.required(),
+ page: baseKeys.page.required(),
+ source: Joi.string(),
+ event: Joi.string().required(),
+ action: Joi.valid("activity_stream_user_event").required(),
+ metadata_source: Joi.string(),
+ highlight_type: Joi.valid(["bookmarks", "recommendation", "history"]),
+ recommender_type: Joi.string(),
+ value: Joi.object().keys({
+ newtab_url_category: Joi.string(),
+ newtab_extension_id: Joi.string(),
+ home_url_category: Joi.string(),
+ home_extension_id: Joi.string(),
+ }),
+ })
+);
+
+export const UTUserEventPing = Joi.array().items(
+ Joi.string().required().valid("activity_stream"),
+ Joi.string().required().valid("event"),
+ Joi.string()
+ .required()
+ .valid([
+ "CLICK",
+ "SEARCH",
+ "BLOCK",
+ "DELETE",
+ "DELETE_CONFIRM",
+ "DIALOG_CANCEL",
+ "DIALOG_OPEN",
+ "OPEN_NEW_WINDOW",
+ "OPEN_PRIVATE_WINDOW",
+ "OPEN_NEWTAB_PREFS",
+ "CLOSE_NEWTAB_PREFS",
+ "BOOKMARK_DELETE",
+ "BOOKMARK_ADD",
+ "PIN",
+ "UNPIN",
+ "SAVE_TO_POCKET",
+ ]),
+ Joi.string().required(),
+ eventsTelemetryExtraKeys
+);
+
+// Use this to validate actions generated from Redux
+export const UserEventAction = Joi.object().keys({
+ type: Joi.string().required(),
+ data: Joi.object()
+ .keys({
+ event: Joi.valid([
+ "CLICK",
+ "SEARCH",
+ "SEARCH_HANDOFF",
+ "BLOCK",
+ "DELETE",
+ "DELETE_CONFIRM",
+ "DIALOG_CANCEL",
+ "DIALOG_OPEN",
+ "OPEN_NEW_WINDOW",
+ "OPEN_PRIVATE_WINDOW",
+ "OPEN_NEWTAB_PREFS",
+ "CLOSE_NEWTAB_PREFS",
+ "BOOKMARK_DELETE",
+ "BOOKMARK_ADD",
+ "PIN",
+ "PREVIEW_REQUEST",
+ "UNPIN",
+ "SAVE_TO_POCKET",
+ "MENU_MOVE_UP",
+ "MENU_MOVE_DOWN",
+ "SCREENSHOT_REQUEST",
+ "MENU_REMOVE",
+ "MENU_COLLAPSE",
+ "MENU_EXPAND",
+ "MENU_MANAGE",
+ "MENU_ADD_TOPSITE",
+ "MENU_PRIVACY_NOTICE",
+ "DELETE_FROM_POCKET",
+ "ARCHIVE_FROM_POCKET",
+ "SKIPPED_SIGNIN",
+ "SUBMIT_EMAIL",
+ "SUBMIT_SIGNIN",
+ "SHOW_PRIVACY_INFO",
+ "CLICK_PRIVACY_INFO",
+ ]).required(),
+ source: Joi.valid(["TOP_SITES", "TOP_STORIES", "HIGHLIGHTS"]),
+ action_position: Joi.number().integer(),
+ value: Joi.object().keys({
+ icon_type: Joi.valid([
+ "tippytop",
+ "rich_icon",
+ "screenshot_with_icon",
+ "screenshot",
+ "no_image",
+ "custom_screenshot",
+ ]),
+ card_type: Joi.valid([
+ "bookmark",
+ "trending",
+ "pinned",
+ "pocket",
+ "search",
+ "spoc",
+ "organic",
+ ]),
+ search_vendor: Joi.valid(["google", "amazon"]),
+ has_flow_params: Joi.bool(),
+ }),
+ })
+ .required(),
+ meta: Joi.object()
+ .keys({
+ to: Joi.valid(MAIN_MESSAGE_TYPE).required(),
+ from: Joi.valid(CONTENT_MESSAGE_TYPE).required(),
+ })
+ .required(),
+});
+
+export const TileSchema = Joi.object().keys({
+ id: Joi.number().integer().required(),
+ pos: Joi.number().integer(),
+});
+
+export const ImpressionStatsPing = Joi.object().keys(
+ Object.assign({}, baseKeys, {
+ source: Joi.string().required(),
+ impression_id: Joi.string().required(),
+ tiles: Joi.array().items(TileSchema).required(),
+ click: Joi.number().integer(),
+ block: Joi.number().integer(),
+ pocket: Joi.number().integer(),
+ })
+);
+
+export const SessionPing = Joi.object().keys(
+ Object.assign({}, baseKeys, {
+ session_id: baseKeys.session_id.required(),
+ page: baseKeys.page.required(),
+ session_duration: Joi.number().integer(),
+ action: Joi.valid("activity_stream_session").required(),
+ profile_creation_date: Joi.number().integer(),
+ perf: Joi.object()
+ .keys({
+ // How long it took in ms for data to be ready for display.
+ highlights_data_late_by_ms: Joi.number().positive(),
+
+ // Timestamp of the action perceived by the user to trigger the load
+ // of this page.
+ //
+ // Not required at least for the error cases where the
+ // observer event doesn't fire
+ load_trigger_ts: Joi.number()
+ .integer()
+ .notes(["server counter", "server counter alert"]),
+
+ // What was the perceived trigger of the load action?
+ //
+ // Not required at least for the error cases where the observer event
+ // doesn't fire
+ load_trigger_type: Joi.valid([
+ "first_window_opened",
+ "menu_plus_or_keyboard",
+ "unexpected",
+ ])
+ .notes(["server counter", "server counter alert"])
+ .required(),
+
+ // How long it took in ms for data to be ready for display.
+ topsites_data_late_by_ms: Joi.number().positive(),
+
+ // When did the topsites element finish painting? Note that, at least for
+ // the first tab to be loaded, and maybe some others, this will be before
+ // topsites has yet to receive screenshots updates from the add-on code,
+ // and is therefore just showing placeholder screenshots.
+ topsites_first_painted_ts: Joi.number()
+ .integer()
+ .notes(["server counter", "server counter alert"]),
+
+ // Information about the quality of TopSites images and icons.
+ topsites_icon_stats: Joi.object().keys({
+ custom_screenshot: Joi.number(),
+ rich_icon: Joi.number(),
+ screenshot: Joi.number(),
+ screenshot_with_icon: Joi.number(),
+ tippytop: Joi.number(),
+ no_image: Joi.number(),
+ }),
+
+ // The count of pinned Top Sites.
+ topsites_pinned: Joi.number(),
+
+ // The count of search shortcut Top Sites.
+ topsites_search_shortcuts: Joi.number(),
+
+ // When the page itself receives an event that document.visibilityState
+ // == visible.
+ //
+ // Not required at least for the (error?) case where the
+ // visibility_event doesn't fire. (It's not clear whether this
+ // can happen in practice, but if it does, we'd like to know about it).
+ visibility_event_rcvd_ts: Joi.number()
+ .integer()
+ .notes(["server counter", "server counter alert"]),
+
+ // The boolean to signify whether the page is preloaded or not.
+ is_preloaded: Joi.bool().required(),
+ })
+ .required(),
+ })
+);
+
+export const ASRouterEventPing = Joi.object()
+ .keys({
+ addon_version: Joi.string().required(),
+ locale: Joi.string().required(),
+ message_id: Joi.string().required(),
+ event: Joi.string().required(),
+ client_id: Joi.string(),
+ impression_id: Joi.string(),
+ })
+ .or("client_id", "impression_id");
+
+export const UTSessionPing = Joi.array().items(
+ Joi.string().required().valid("activity_stream"),
+ Joi.string().required().valid("end"),
+ Joi.string().required().valid("session"),
+ Joi.string().required(),
+ eventsTelemetryExtraKeys
+);
+
+export function chaiAssertions(_chai, utils) {
+ const { Assertion } = _chai;
+
+ Assertion.addMethod("validate", function (schema, schemaName) {
+ const { error } = Joi.validate(this._obj, schema, { allowUnknown: false });
+ this.assert(
+ !error,
+ `Expected to be ${
+ schemaName ? `a valid ${schemaName}` : "valid"
+ } but there were errors: ${error}`
+ );
+ });
+
+ const assertions = {
+ /**
+ * assert.validate - Validates an item given a Joi schema
+ *
+ * @param {any} actual The item to validate
+ * @param {obj} schema A Joi schema
+ */
+ validate(actual, schema, schemaName) {
+ new Assertion(actual).validate(schema, schemaName);
+ },
+
+ /**
+ * isUserEventAction - Passes if the item is a valid UserEvent action
+ *
+ * @param {any} actual The item to validate
+ */
+ isUserEventAction(actual) {
+ new Assertion(actual).validate(UserEventAction, "UserEventAction");
+ },
+ };
+
+ Object.assign(_chai.assert, assertions);
+}
diff --git a/browser/components/newtab/test/unit/aboutwelcome/AWScreenUtils.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/AWScreenUtils.test.jsx
new file mode 100644
index 0000000000..a9f401f6b7
--- /dev/null
+++ b/browser/components/newtab/test/unit/aboutwelcome/AWScreenUtils.test.jsx
@@ -0,0 +1,140 @@
+import { AWScreenUtils } from "lib/AWScreenUtils.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+import { ASRouter } from "lib/ASRouter.jsm";
+
+describe("AWScreenUtils", () => {
+ let sandbox;
+ let globals;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ globals.set({
+ ASRouter,
+ ASRouterTargeting: {
+ Environment: {},
+ },
+ });
+
+ sandbox = sinon.createSandbox();
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+ describe("removeScreens", () => {
+ it("should run callback function once for each array element", async () => {
+ const callback = sandbox.stub().resolves(false);
+ const arr = ["foo", "bar"];
+ await AWScreenUtils.removeScreens(arr, callback);
+ assert.calledTwice(callback);
+ });
+ it("should remove screen when passed function evaluates true", async () => {
+ const callback = sandbox.stub().resolves(true);
+ const arr = ["foo", "bar"];
+ await AWScreenUtils.removeScreens(arr, callback);
+ assert.deepEqual(arr, []);
+ });
+ });
+ describe("evaluateScreenTargeting", () => {
+ it("should return the eval result if the eval succeeds", async () => {
+ const evalStub = sandbox.stub(ASRouter, "evaluateExpression").resolves({
+ evaluationStatus: {
+ success: true,
+ result: false,
+ },
+ });
+ const result = await AWScreenUtils.evaluateScreenTargeting(
+ "test expression"
+ );
+ assert.calledOnce(evalStub);
+ assert.equal(result, false);
+ });
+ it("should return true if the targeting eval fails", async () => {
+ const evalStub = sandbox.stub(ASRouter, "evaluateExpression").resolves({
+ evaluationStatus: {
+ success: false,
+ result: false,
+ },
+ });
+ const result = await AWScreenUtils.evaluateScreenTargeting(
+ "test expression"
+ );
+ assert.calledOnce(evalStub);
+ assert.equal(result, true);
+ });
+ });
+ describe("evaluateTargetingAndRemoveScreens", () => {
+ it("should manipulate an array of screens", async () => {
+ const screens = [
+ {
+ id: "first",
+ targeting: true,
+ },
+ {
+ id: "second",
+ targeting: false,
+ },
+ ];
+
+ const expectedScreens = [
+ {
+ id: "first",
+ targeting: true,
+ },
+ ];
+ sandbox.stub(ASRouter, "evaluateExpression").callsFake(targeting => {
+ return {
+ evaluationStatus: {
+ success: true,
+ result: targeting.expression,
+ },
+ };
+ });
+ const evaluatedStrings =
+ await AWScreenUtils.evaluateTargetingAndRemoveScreens(screens);
+ assert.deepEqual(evaluatedStrings, expectedScreens);
+ });
+ it("should not remove screens with no targeting", async () => {
+ const screens = [
+ {
+ id: "first",
+ },
+ {
+ id: "second",
+ targeting: false,
+ },
+ ];
+
+ const expectedScreens = [
+ {
+ id: "first",
+ },
+ ];
+ sandbox
+ .stub(AWScreenUtils, "evaluateScreenTargeting")
+ .callsFake(targeting => {
+ if (targeting === undefined) {
+ return true;
+ }
+ return targeting;
+ });
+ const evaluatedStrings =
+ await AWScreenUtils.evaluateTargetingAndRemoveScreens(screens);
+ assert.deepEqual(evaluatedStrings, expectedScreens);
+ });
+ });
+
+ describe("addScreenImpression", () => {
+ it("Should call addScreenImpression with provided screen ID", () => {
+ const addScreenImpressionStub = sandbox.stub(
+ ASRouter,
+ "addScreenImpression"
+ );
+ const testScreen = { id: "test" };
+ AWScreenUtils.addScreenImpression(testScreen);
+
+ assert.calledOnce(addScreenImpressionStub);
+ assert.equal(addScreenImpressionStub.firstCall.args[0].id, testScreen.id);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/aboutwelcome/CTAParagraph.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/CTAParagraph.test.jsx
new file mode 100644
index 0000000000..57773b0e82
--- /dev/null
+++ b/browser/components/newtab/test/unit/aboutwelcome/CTAParagraph.test.jsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { shallow } from "enzyme";
+import { CTAParagraph } from "content-src/aboutwelcome/components/CTAParagraph";
+
+describe("CTAParagraph component", () => {
+ let sandbox;
+ let wrapper;
+ let handleAction;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ handleAction = sandbox.stub();
+ wrapper = shallow(
+ <CTAParagraph
+ content={{
+ text: {
+ raw: "Link Text",
+ string_name: "Test Name",
+ },
+ }}
+ handleAction={handleAction}
+ />
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render CTAParagraph component", () => {
+ assert.ok(wrapper.exists());
+ });
+
+ it("should render CTAParagraph component if only CTA text is passed", () => {
+ wrapper.setProps({ content: { text: "CTA Text" } });
+ assert.ok(wrapper.exists());
+ });
+
+ it("should call handleAction method when button is link is clicked", () => {
+ const btnLink = wrapper.find(".cta-paragraph span");
+ btnLink.simulate("click");
+ assert.calledOnce(handleAction);
+ });
+
+ it("should not render CTAParagraph component if CTA text is not passed", () => {
+ wrapper.setProps({ content: { text: null } });
+ assert.ok(wrapper.isEmptyRender());
+ });
+});
diff --git a/browser/components/newtab/test/unit/aboutwelcome/HeroImage.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/HeroImage.test.jsx
new file mode 100644
index 0000000000..8c9bdc8e50
--- /dev/null
+++ b/browser/components/newtab/test/unit/aboutwelcome/HeroImage.test.jsx
@@ -0,0 +1,40 @@
+import React from "react";
+import { shallow } from "enzyme";
+import { HeroImage } from "content-src/aboutwelcome/components/HeroImage";
+
+describe("HeroImage component", () => {
+ const imageUrl = "https://example.com";
+ const imageHeight = "100px";
+ const imageAlt = "Alt text";
+
+ let wrapper;
+ beforeEach(() => {
+ wrapper = shallow(
+ <HeroImage url={imageUrl} alt={imageAlt} height={imageHeight} />
+ );
+ });
+
+ it("should render HeroImage component", () => {
+ assert.ok(wrapper.exists());
+ });
+
+ it("should render an image element with src prop", () => {
+ let imgEl = wrapper.find("img");
+ assert.strictEqual(imgEl.prop("src"), imageUrl);
+ });
+
+ it("should render image element with alt text prop", () => {
+ let imgEl = wrapper.find("img");
+ assert.equal(imgEl.prop("alt"), imageAlt);
+ });
+
+ it("should render an image with a set height prop", () => {
+ let imgEl = wrapper.find("img");
+ assert.propertyVal(imgEl.prop("style"), "height", imageHeight);
+ });
+
+ it("should not render HeroImage component", () => {
+ wrapper.setProps({ url: null });
+ assert.ok(wrapper.isEmptyRender());
+ });
+});
diff --git a/browser/components/newtab/test/unit/aboutwelcome/MRColorways.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MRColorways.test.jsx
new file mode 100644
index 0000000000..c8829d76a7
--- /dev/null
+++ b/browser/components/newtab/test/unit/aboutwelcome/MRColorways.test.jsx
@@ -0,0 +1,328 @@
+import React from "react";
+import { shallow } from "enzyme";
+import {
+ Colorways,
+ computeColorWay,
+ ColorwayDescription,
+ computeVariationIndex,
+} from "content-src/aboutwelcome/components/MRColorways";
+import { WelcomeScreen } from "content-src/aboutwelcome/components/MultiStageAboutWelcome";
+
+describe("Multistage AboutWelcome module", () => {
+ let sandbox;
+ let COLORWAY_SCREEN_PROPS;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ COLORWAY_SCREEN_PROPS = {
+ id: "test-colorway-screen",
+ totalNumberofScreens: 1,
+ content: {
+ subtitle: "test subtitle",
+ tiles: {
+ type: "colorway",
+ action: {
+ theme: "<event>",
+ },
+ defaultVariationIndex: 0,
+ systemVariations: ["automatic", "light"],
+ variations: ["soft", "bold"],
+ colorways: [
+ {
+ id: "default",
+ label: "Default",
+ },
+ {
+ id: "abstract",
+ label: "Abstract",
+ },
+ ],
+ },
+ primary_button: {
+ action: {},
+ label: "test button",
+ },
+ },
+ messageId: "test-mr-colorway-screen",
+ activeTheme: "automatic",
+ };
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ describe("MRColorway component", () => {
+ it("should render WelcomeScreen", () => {
+ const wrapper = shallow(<WelcomeScreen {...COLORWAY_SCREEN_PROPS} />);
+
+ assert.ok(wrapper.exists());
+ });
+
+ it("should use default when activeTheme is not set", () => {
+ const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />);
+ wrapper.setProps({ activeTheme: null });
+
+ const colorwaysOptionIcons = wrapper.find(
+ ".tiles-theme-section .theme .icon"
+ );
+ assert.strictEqual(colorwaysOptionIcons.length, 2);
+
+ // Default automatic theme is selected by default
+ assert.strictEqual(
+ colorwaysOptionIcons.first().prop("className").includes("selected"),
+ true
+ );
+
+ assert.strictEqual(
+ colorwaysOptionIcons.first().prop("className").includes("default"),
+ true
+ );
+ });
+
+ it("should use default when activeTheme is alpenglow", () => {
+ const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />);
+ wrapper.setProps({ activeTheme: "alpenglow" });
+
+ const colorwaysOptionIcons = wrapper.find(
+ ".tiles-theme-section .theme .icon"
+ );
+ assert.strictEqual(colorwaysOptionIcons.length, 2);
+
+ // Default automatic theme is selected when unsupported in colorway alpenglow theme is active
+ assert.strictEqual(
+ colorwaysOptionIcons.first().prop("className").includes("selected"),
+ true
+ );
+
+ assert.strictEqual(
+ colorwaysOptionIcons.first().prop("className").includes("default"),
+ true
+ );
+ });
+
+ it("should render colorways options", () => {
+ const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />);
+
+ const colorwaysOptions = wrapper.find(
+ ".tiles-theme-section .theme input[name='theme']"
+ );
+
+ const colorwaysOptionIcons = wrapper.find(
+ ".tiles-theme-section .theme .icon"
+ );
+
+ const colorwaysLabels = wrapper.find(
+ ".tiles-theme-section .theme span.sr-only"
+ );
+
+ assert.strictEqual(colorwaysOptions.length, 2);
+ assert.strictEqual(colorwaysOptionIcons.length, 2);
+ assert.strictEqual(colorwaysLabels.length, 2);
+
+ // First colorway option
+ // Default theme radio option is selected by default
+ assert.strictEqual(
+ colorwaysOptionIcons.first().prop("className").includes("selected"),
+ true
+ );
+
+ //Colorway should be using id property
+ assert.strictEqual(
+ colorwaysOptions.first().prop("data-colorway"),
+ "default"
+ );
+
+ // Second colorway option
+ assert.strictEqual(
+ colorwaysOptionIcons.last().prop("className").includes("selected"),
+ false
+ );
+
+ //Colorway should be using id property
+ assert.strictEqual(
+ colorwaysOptions.last().prop("data-colorway"),
+ "abstract"
+ );
+
+ //Colorway should be labelled for screen readers (parent label is for tooltip only, and does not describe the Colorway)
+ assert.strictEqual(
+ colorwaysOptions.last().prop("aria-labelledby"),
+ "abstract-label"
+ );
+ });
+
+ it("should handle colorway clicks", () => {
+ sandbox.stub(React, "useEffect").callsFake((fn, vals) => {
+ if (vals === undefined) {
+ fn();
+ } else if (vals[0] === "in") {
+ fn();
+ }
+ });
+
+ const handleAction = sandbox.stub();
+ const wrapper = shallow(
+ <Colorways handleAction={handleAction} {...COLORWAY_SCREEN_PROPS} />
+ );
+ const colorwaysOptions = wrapper.find(
+ ".tiles-theme-section .theme input[name='theme']"
+ );
+
+ let props = wrapper.find(ColorwayDescription).props();
+ assert.propertyVal(props.colorway, "label", "Default");
+
+ const option = colorwaysOptions.last();
+ assert.propertyVal(option.props(), "value", "abstract-soft");
+ colorwaysOptions.last().simulate("click");
+ assert.calledOnce(handleAction);
+ });
+
+ it("should render colorway description", () => {
+ const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />);
+
+ let descriptionsWrapper = wrapper.find(ColorwayDescription);
+ assert.ok(descriptionsWrapper.exists());
+
+ let props = descriptionsWrapper.props();
+
+ // Colorway description should display Default theme desc by default
+ assert.strictEqual(props.colorway.label, "Default");
+ });
+
+ it("ColorwayDescription should display active colorway desc", () => {
+ let TEST_COLORWAY_PROPS = {
+ colorway: {
+ label: "Activist",
+ description: "Test Activist",
+ },
+ };
+ const descWrapper = shallow(
+ <ColorwayDescription {...TEST_COLORWAY_PROPS} />
+ );
+ assert.ok(descWrapper.exists());
+ const descText = descWrapper.find(".colorway-text");
+ assert.equal(
+ descText.props()["data-l10n-args"].includes("Activist"),
+ true
+ );
+ });
+
+ it("should computeColorWayId for default active theme", () => {
+ let TEST_COLORWAY_PROPS = {
+ ...COLORWAY_SCREEN_PROPS,
+ };
+
+ const colorwayId = computeColorWay(
+ TEST_COLORWAY_PROPS.activeTheme,
+ TEST_COLORWAY_PROPS.content.tiles.systemVariations
+ );
+ assert.strictEqual(colorwayId, "default");
+ });
+
+ it("should computeColorWayId for non-default active theme", () => {
+ let TEST_COLORWAY_PROPS = {
+ ...COLORWAY_SCREEN_PROPS,
+ activeTheme: "abstract-soft",
+ };
+
+ const colorwayId = computeColorWay(
+ TEST_COLORWAY_PROPS.activeTheme,
+ TEST_COLORWAY_PROPS.content.tiles.systemVariations
+ );
+ assert.strictEqual(colorwayId, "abstract");
+ });
+
+ it("should computeVariationIndex for default active theme", () => {
+ let TEST_COLORWAY_PROPS = {
+ ...COLORWAY_SCREEN_PROPS,
+ };
+
+ const variationIndex = computeVariationIndex(
+ TEST_COLORWAY_PROPS.activeTheme,
+ TEST_COLORWAY_PROPS.content.tiles.systemVariations,
+ TEST_COLORWAY_PROPS.content.tiles.variations,
+ TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex
+ );
+ assert.strictEqual(
+ variationIndex,
+ TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex
+ );
+ });
+
+ it("should computeVariationIndex for active theme", () => {
+ let TEST_COLORWAY_PROPS = {
+ ...COLORWAY_SCREEN_PROPS,
+ };
+
+ const variationIndex = computeVariationIndex(
+ "light",
+ TEST_COLORWAY_PROPS.content.tiles.systemVariations,
+ TEST_COLORWAY_PROPS.content.tiles.variations,
+ TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex
+ );
+ assert.strictEqual(variationIndex, 1);
+ });
+
+ it("should computeVariationIndex for colorway theme", () => {
+ let TEST_COLORWAY_PROPS = {
+ ...COLORWAY_SCREEN_PROPS,
+ };
+
+ const variationIndex = computeVariationIndex(
+ "abstract-bold",
+ TEST_COLORWAY_PROPS.content.tiles.systemVariations,
+ TEST_COLORWAY_PROPS.content.tiles.variations,
+ TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex
+ );
+ assert.strictEqual(variationIndex, 1);
+ });
+
+ describe("random colorways", () => {
+ let test;
+ beforeEach(() => {
+ COLORWAY_SCREEN_PROPS.handleAction = sandbox.stub();
+ sandbox.stub(window, "matchMedia");
+ // eslint-disable-next-line max-nested-callbacks
+ sandbox.stub(React, "useEffect").callsFake((fn, vals) => {
+ if (vals?.length === 0) {
+ fn();
+ }
+ });
+ test = () => {
+ shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />);
+ return COLORWAY_SCREEN_PROPS.handleAction.firstCall.firstArg
+ .currentTarget;
+ };
+ });
+
+ it("should select a random colorway", () => {
+ const { value } = test();
+
+ assert.strictEqual(value, "abstract-soft");
+ assert.calledThrice(React.useEffect);
+ assert.notCalled(window.matchMedia);
+ });
+
+ it("should select a random soft colorway when not dark", () => {
+ window.matchMedia.returns({ matches: false });
+ COLORWAY_SCREEN_PROPS.content.tiles.darkVariation = 1;
+
+ const { value } = test();
+
+ assert.strictEqual(value, "abstract-soft");
+ assert.calledThrice(React.useEffect);
+ assert.calledOnce(window.matchMedia);
+ });
+
+ it("should select a random bold colorway when dark", () => {
+ window.matchMedia.returns({ matches: true });
+ COLORWAY_SCREEN_PROPS.content.tiles.darkVariation = 1;
+
+ const { value } = test();
+
+ assert.strictEqual(value, "abstract-bold");
+ assert.calledThrice(React.useEffect);
+ assert.calledOnce(window.matchMedia);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/aboutwelcome/MobileDownloads.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MobileDownloads.test.jsx
new file mode 100644
index 0000000000..e3ed5d2bb0
--- /dev/null
+++ b/browser/components/newtab/test/unit/aboutwelcome/MobileDownloads.test.jsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { shallow, mount } from "enzyme";
+import { GlobalOverrider } from "test/unit/utils";
+import { MobileDownloads } from "content-src/aboutwelcome/components/MobileDownloads";
+
+describe("Multistage AboutWelcome MobileDownloads module", () => {
+ let globals;
+ let sandbox;
+
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ globals.set({
+ AWFinish: () => Promise.resolve(),
+ AWSendToDeviceEmailsSupported: () => Promise.resolve(),
+ });
+ sandbox = sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("Mobile Downloads component", () => {
+ const MOBILE_DOWNLOADS_PROPS = {
+ data: {
+ QR_code: {
+ image_url:
+ "chrome://browser/components/privatebrowsing/content/assets/focus-qr-code.svg",
+ alt_text: {
+ string_id: "spotlight-focus-promo-qr-code",
+ },
+ },
+ email: {
+ link_text: "Email yourself a link",
+ },
+ marketplace_buttons: ["ios", "android"],
+ },
+ handleAction: () => {
+ window.AWFinish();
+ },
+ };
+
+ it("should render MobileDownloads", () => {
+ const wrapper = shallow(<MobileDownloads {...MOBILE_DOWNLOADS_PROPS} />);
+
+ assert.ok(wrapper.exists());
+ });
+
+ it("should handle action on markeplace badge click", () => {
+ const wrapper = mount(<MobileDownloads {...MOBILE_DOWNLOADS_PROPS} />);
+
+ const stub = sandbox.stub(global, "AWFinish");
+ wrapper.find(".ios button").simulate("click");
+ wrapper.find(".android button").simulate("click");
+
+ assert.calledTwice(stub);
+ });
+
+ it("should handle action on email button click", () => {
+ const wrapper = shallow(<MobileDownloads {...MOBILE_DOWNLOADS_PROPS} />);
+
+ const stub = sandbox.stub(global, "AWFinish");
+ wrapper.find("button.email-link").simulate("click");
+
+ assert.calledOnce(stub);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/aboutwelcome/MultiSelect.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MultiSelect.test.jsx
new file mode 100644
index 0000000000..cb1ce3651a
--- /dev/null
+++ b/browser/components/newtab/test/unit/aboutwelcome/MultiSelect.test.jsx
@@ -0,0 +1,151 @@
+import React from "react";
+import { shallow, mount } from "enzyme";
+import { MultiSelect } from "content-src/aboutwelcome/components/MultiSelect";
+
+describe("Multistage AboutWelcome module", () => {
+ let sandbox;
+ let MULTISELECT_SCREEN_PROPS;
+ let setActiveMultiSelect;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ setActiveMultiSelect = sandbox.stub();
+ MULTISELECT_SCREEN_PROPS = {
+ id: "multiselect-screen",
+ content: {
+ position: "split",
+ split_narrow_bkg_position: "-60px",
+ image_alt_text: {
+ string_id: "mr2022-onboarding-default-image-alt",
+ },
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-settodefault.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)",
+ progress_bar: true,
+ logo: {},
+ title: "Test Title",
+ subtitle: "Test SubTitle",
+ tiles: {
+ type: "multiselect",
+ data: [
+ {
+ id: "checkbox-1",
+ defaultValue: true,
+ label: {
+ string_id: "mr2022-onboarding-set-default-primary-button-label",
+ },
+ action: {
+ type: "SET_DEFAULT_BROWSER",
+ },
+ },
+ {
+ id: "checkbox-2",
+ defaultValue: true,
+ label: "Test Checkbox 2",
+ action: {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: {},
+ },
+ },
+ {
+ id: "checkbox-3",
+ defaultValue: false,
+ label: "Test Checkbox 3",
+ action: {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: {},
+ },
+ },
+ ],
+ },
+ primary_button: {
+ label: "Save and Continue",
+ action: {
+ type: "MULTI_ACTION",
+ collectSelect: true,
+ navigate: true,
+ data: { actions: [] },
+ },
+ },
+ secondary_button: {
+ label: "Skip",
+ action: {
+ navigate: true,
+ },
+ has_arrow_icon: true,
+ },
+ },
+ };
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ describe("MultiSelect component", () => {
+ it("should call setActiveMultiSelect with ids of checkboxes with defaultValue true", () => {
+ const wrapper = mount(
+ <MultiSelect
+ setActiveMultiSelect={setActiveMultiSelect}
+ {...MULTISELECT_SCREEN_PROPS}
+ />
+ );
+
+ wrapper.setProps({ activeMultiSelect: null });
+ assert.calledOnce(setActiveMultiSelect);
+ assert.calledWith(setActiveMultiSelect, ["checkbox-1", "checkbox-2"]);
+ });
+
+ it("should use activeMultiSelect ids to set checked state for respective checkbox", () => {
+ const wrapper = mount(
+ <MultiSelect
+ setActiveMultiSelect={setActiveMultiSelect}
+ {...MULTISELECT_SCREEN_PROPS}
+ />
+ );
+
+ wrapper.setProps({ activeMultiSelect: ["checkbox-1", "checkbox-2"] });
+ const checkBoxes = wrapper.find(".checkbox-container input");
+ assert.strictEqual(checkBoxes.length, 3);
+
+ assert.strictEqual(checkBoxes.first().props().checked, true);
+ assert.strictEqual(checkBoxes.at(1).props().checked, true);
+ assert.strictEqual(checkBoxes.last().props().checked, false);
+ });
+
+ it("should filter out id when checkbox is unchecked", () => {
+ const wrapper = shallow(
+ <MultiSelect
+ setActiveMultiSelect={setActiveMultiSelect}
+ {...MULTISELECT_SCREEN_PROPS}
+ />
+ );
+ wrapper.setProps({ activeMultiSelect: ["checkbox-1", "checkbox-2"] });
+
+ const ckbx1 = wrapper.find(".checkbox-container input").at(0);
+ assert.strictEqual(ckbx1.prop("value"), "checkbox-1");
+ ckbx1.simulate("change", {
+ currentTarget: { value: "checkbox-1", checked: false },
+ });
+ assert.calledWith(setActiveMultiSelect, ["checkbox-2"]);
+ });
+
+ it("should add id when checkbox is checked", () => {
+ const wrapper = shallow(
+ <MultiSelect
+ setActiveMultiSelect={setActiveMultiSelect}
+ {...MULTISELECT_SCREEN_PROPS}
+ />
+ );
+ wrapper.setProps({ activeMultiSelect: ["checkbox-1", "checkbox-2"] });
+
+ const ckbx3 = wrapper.find(".checkbox-container input").at(2);
+ assert.strictEqual(ckbx3.prop("value"), "checkbox-3");
+ ckbx3.simulate("change", {
+ currentTarget: { value: "checkbox-3", checked: true },
+ });
+ assert.calledWith(setActiveMultiSelect, [
+ "checkbox-1",
+ "checkbox-2",
+ "checkbox-3",
+ ]);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx
new file mode 100644
index 0000000000..22070101cf
--- /dev/null
+++ b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx
@@ -0,0 +1,564 @@
+import { AboutWelcomeDefaults } from "aboutwelcome/lib/AboutWelcomeDefaults.jsm";
+import { MultiStageProtonScreen } from "content-src/aboutwelcome/components/MultiStageProtonScreen";
+import { AWScreenUtils } from "lib/AWScreenUtils.jsm";
+import React from "react";
+import { mount } from "enzyme";
+
+describe("MultiStageAboutWelcomeProton module", () => {
+ let sandbox;
+ let clock;
+ beforeEach(() => {
+ clock = sinon.useFakeTimers();
+ sandbox = sinon.createSandbox();
+ });
+ afterEach(() => {
+ clock.restore();
+ sandbox.restore();
+ });
+
+ describe("MultiStageAWProton component", () => {
+ it("should render MultiStageProton Screen", () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ subtitle: "test subtitle",
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ });
+
+ it("should render secondary section for split positioned screens", () => {
+ const SCREEN_PROPS = {
+ content: {
+ position: "split",
+ title: "test title",
+ hero_text: "test subtitle",
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.find(".welcome-text h1").text(), "test title");
+ assert.equal(
+ wrapper.find(".section-secondary h1").text(),
+ "test subtitle"
+ );
+ assert.equal(wrapper.find("main").prop("pos"), "split");
+ });
+
+ it("should render secondary section with content background for split positioned screens", () => {
+ const BACKGROUND_URL =
+ "chrome://activity-stream/content/data/content/assets/confetti.svg";
+ const SCREEN_PROPS = {
+ content: {
+ position: "split",
+ background: `url(${BACKGROUND_URL}) var(--mr-secondary-position) no-repeat`,
+ split_narrow_bkg_position: "10px",
+ title: "test title",
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.ok(
+ wrapper
+ .find("div.section-secondary")
+ .prop("style")
+ .background.includes("--mr-secondary-position")
+ );
+ assert.ok(
+ wrapper.find("div.section-secondary").prop("style")[
+ "--mr-secondary-background-position-y"
+ ],
+ "10px"
+ );
+ });
+
+ it("should render with secondary section for split positioned screens", () => {
+ const SCREEN_PROPS = {
+ content: {
+ position: "split",
+ title: "test title",
+ hero_text: "test subtitle",
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.find(".welcome-text h1").text(), "test title");
+ assert.equal(
+ wrapper.find(".section-secondary h1").text(),
+ "test subtitle"
+ );
+ assert.equal(wrapper.find("main").prop("pos"), "split");
+ });
+
+ it("should render with no secondary section for center positioned screens", () => {
+ const SCREEN_PROPS = {
+ content: {
+ position: "center",
+ title: "test title",
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.find(".section-secondary").exists(), false);
+ assert.equal(wrapper.find(".welcome-text h1").text(), "test title");
+ assert.equal(wrapper.find("main").prop("pos"), "center");
+ });
+
+ it("should not render multiple action buttons if an additional button does not exist", () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ primary_button: {
+ label: "test primary button",
+ },
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.isFalse(wrapper.find(".additional-cta").exists());
+ });
+
+ it("should render an additional action button with primary styling if no style has been specified", () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ primary_button: {
+ label: "test primary button",
+ },
+ additional_button: {
+ label: "test additional button",
+ },
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.isTrue(wrapper.find(".additional-cta.primary").exists());
+ });
+
+ it("should render an additional action button with secondary styling", () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ primary_button: {
+ label: "test primary button",
+ },
+ additional_button: {
+ label: "test additional button",
+ style: "secondary",
+ },
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.find(".additional-cta.secondary").exists(), true);
+ });
+
+ it("should render an additional action button with primary styling", () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ primary_button: {
+ label: "test primary button",
+ },
+ additional_button: {
+ label: "test additional button",
+ style: "primary",
+ },
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.find(".additional-cta.primary").exists(), true);
+ });
+
+ it("should render an additional action with link styling", () => {
+ const SCREEN_PROPS = {
+ content: {
+ position: "split",
+ title: "test title",
+ primary_button: {
+ label: "test primary button",
+ },
+ additional_button: {
+ label: "test additional button",
+ style: "link",
+ },
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.find(".additional-cta.cta-link").exists(), true);
+ });
+
+ it("should render an additional button with vertical orientation", () => {
+ const SCREEN_PROPS = {
+ content: {
+ position: "center",
+ title: "test title",
+ primary_button: {
+ label: "test primary button",
+ },
+ additional_button: {
+ label: "test additional button",
+ style: "secondary",
+ flow: "column",
+ },
+ },
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.equal(
+ wrapper.find(".additional-cta-container[flow='column']").exists(),
+ true
+ );
+ });
+
+ it("should not render a progress bar if there is 1 step", () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ progress_bar: true,
+ },
+ isSingleScreen: true,
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.find(".steps.progress-bar").exists(), false);
+ });
+
+ it("should render a progress bar if there are 2 steps", () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ progress_bar: true,
+ },
+ totalNumberOfScreens: 2,
+ };
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.find(".steps.progress-bar").exists(), true);
+ });
+ });
+
+ describe("AboutWelcomeDefaults for proton", () => {
+ const getData = () => AboutWelcomeDefaults.getDefaults();
+
+ async function prepConfig(config, evalFalseScreenIds) {
+ let data = await getData();
+
+ if (evalFalseScreenIds?.length) {
+ data.screens.forEach(async screen => {
+ if (evalFalseScreenIds.includes(screen.id)) {
+ screen.targeting = false;
+ }
+ });
+ data.screens = await AWScreenUtils.evaluateTargetingAndRemoveScreens(
+ data.screens
+ );
+ }
+
+ return AboutWelcomeDefaults.prepareContentForReact({
+ ...data,
+ ...config,
+ });
+ }
+ beforeEach(() => {
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
+ sandbox.stub(AWScreenUtils, "evaluateScreenTargeting").returnsArg(0);
+ // This is necessary because there are still screens being removed with
+ // `removeScreens` in `prepareContentForReact()`. Once we've migrated
+ // to using screen targeting instead of manually removing screens,
+ // we can remove this stub.
+ sandbox
+ .stub(global.AWScreenUtils, "removeScreens")
+ .callsFake((screens, callback) =>
+ AWScreenUtils.removeScreens(screens, callback)
+ );
+ });
+ it("should have 'pin' button by default", async () => {
+ const data = await prepConfig({ needPin: true }, [
+ "AW_EASY_SETUP",
+ "AW_WELCOME_BACK",
+ ]);
+ assert.propertyVal(
+ data.screens[0].content.primary_button.action,
+ "type",
+ "PIN_FIREFOX_TO_TASKBAR"
+ );
+ });
+ it("should have 'pin' button if we need default and pin", async () => {
+ const data = await prepConfig(
+ {
+ needDefault: true,
+ needPin: true,
+ },
+ ["AW_EASY_SETUP", "AW_WELCOME_BACK"]
+ );
+
+ assert.propertyVal(
+ data.screens[0].content.primary_button.action,
+ "type",
+ "PIN_FIREFOX_TO_TASKBAR"
+ );
+ assert.propertyVal(data.screens[0], "id", "AW_PIN_FIREFOX");
+ assert.propertyVal(data.screens[1], "id", "AW_SET_DEFAULT");
+ assert.lengthOf(data.screens, getData().screens.length - 3);
+ });
+ it("should keep 'pin' and remove 'default' if already default", async () => {
+ const data = await prepConfig({ needPin: true }, [
+ "AW_EASY_SETUP",
+ "AW_WELCOME_BACK",
+ ]);
+
+ assert.propertyVal(data.screens[0], "id", "AW_PIN_FIREFOX");
+ assert.propertyVal(data.screens[1], "id", "AW_IMPORT_SETTINGS");
+ assert.lengthOf(data.screens, getData().screens.length - 4);
+ });
+ it("should switch to 'default' if already pinned", async () => {
+ const data = await prepConfig({ needDefault: true }, [
+ "AW_EASY_SETUP",
+ "AW_WELCOME_BACK",
+ ]);
+
+ assert.propertyVal(data.screens[0], "id", "AW_ONLY_DEFAULT");
+ assert.propertyVal(data.screens[1], "id", "AW_IMPORT_SETTINGS");
+ assert.lengthOf(data.screens, getData().screens.length - 4);
+ });
+ it("should switch to 'start' if already pinned and default", async () => {
+ const data = await prepConfig({}, ["AW_EASY_SETUP", "AW_WELCOME_BACK"]);
+
+ assert.propertyVal(data.screens[0], "id", "AW_GET_STARTED");
+ assert.propertyVal(data.screens[1], "id", "AW_IMPORT_SETTINGS");
+ assert.lengthOf(data.screens, getData().screens.length - 4);
+ });
+ it("should have a FxA button", async () => {
+ const data = await prepConfig({}, ["AW_WELCOME_BACK"]);
+
+ assert.notProperty(data, "skipFxA");
+ assert.property(data.screens[0].content, "secondary_button_top");
+ });
+ it("should remove the FxA button if pref disabled", async () => {
+ global.Services.prefs.getBoolPref.returns(false);
+
+ const data = await prepConfig();
+
+ assert.property(data, "skipFxA", true);
+ assert.notProperty(data.screens[0].content, "secondary_button_top");
+ });
+ it("should remove the caption if deleteIfNotEn is true", async () => {
+ sandbox.stub(global.Services.locale, "appLocaleAsBCP47").value("de");
+
+ const data = await prepConfig({
+ id: "DEFAULT_ABOUTWELCOME_PROTON",
+ template: "multistage",
+ transitions: true,
+ background_url:
+ "chrome://activity-stream/content/data/content/assets/confetti.svg",
+ screens: [
+ {
+ id: "AW_PIN_FIREFOX",
+ content: {
+ position: "corner",
+ help_text: {
+ deleteIfNotEn: true,
+ string_id: "mr1-onboarding-welcome-image-caption",
+ },
+ },
+ },
+ ],
+ });
+
+ assert.notProperty(data.screens[0].content, "help_text");
+ });
+ });
+
+ describe("AboutWelcomeDefaults for MR split template proton", () => {
+ const getData = () => AboutWelcomeDefaults.getDefaults(true);
+ beforeEach(() => {
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
+ });
+
+ it("should use 'split' position template by default", async () => {
+ const data = await getData();
+ assert.propertyVal(data.screens[0].content, "position", "split");
+ });
+
+ it("should not include noodles by default", async () => {
+ const data = await getData();
+ assert.notProperty(data.screens[0].content, "has_noodles");
+ });
+ });
+
+ describe("AboutWelcomeDefaults prepareMobileDownload", () => {
+ const TEST_CONTENT = {
+ screens: [
+ {
+ id: "AW_MOBILE_DOWNLOAD",
+ content: {
+ title: "test",
+ hero_image: {
+ url: "https://example.com/test.svg",
+ },
+ cta_paragraph: {
+ text: {},
+ action: {},
+ },
+ },
+ },
+ ],
+ };
+ it("should not set url for default qrcode svg", async () => {
+ sandbox.stub(global.AppConstants, "isChinaRepack").returns(false);
+ const data = await AboutWelcomeDefaults.prepareContentForReact(
+ TEST_CONTENT
+ );
+ assert.propertyVal(
+ data.screens[0].content.hero_image,
+ "url",
+ "https://example.com/test.svg"
+ );
+ });
+ it("should set url for cn qrcode svg", async () => {
+ sandbox.stub(global.AppConstants, "isChinaRepack").returns(true);
+ const data = await AboutWelcomeDefaults.prepareContentForReact(
+ TEST_CONTENT
+ );
+ assert.propertyVal(
+ data.screens[0].content.hero_image,
+ "url",
+ "https://example.com/test-cn.svg"
+ );
+ });
+ });
+
+ describe("AboutWelcomeDefaults prepareContentForReact", () => {
+ it("should not set action without screens", async () => {
+ const data = await AboutWelcomeDefaults.prepareContentForReact({
+ ua: "test",
+ });
+
+ assert.propertyVal(data, "ua", "test");
+ assert.notProperty(data, "screens");
+ });
+ it("should set action for import action", async () => {
+ const TEST_CONTENT = {
+ ua: "test",
+ screens: [
+ {
+ id: "AW_IMPORT_SETTINGS",
+ content: {
+ primary_button: {
+ action: {
+ type: "SHOW_MIGRATION_WIZARD",
+ },
+ },
+ },
+ },
+ ],
+ };
+ const data = await AboutWelcomeDefaults.prepareContentForReact(
+ TEST_CONTENT
+ );
+ assert.propertyVal(data, "ua", "test");
+ assert.propertyVal(
+ data.screens[0].content.primary_button.action.data,
+ "source",
+ "test"
+ );
+ });
+ it("should not set action if the action type != SHOW_MIGRATION_WIZARD", async () => {
+ const TEST_CONTENT = {
+ ua: "test",
+ screens: [
+ {
+ id: "AW_IMPORT_SETTINGS",
+ content: {
+ primary_button: {
+ action: {
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ data: {},
+ },
+ },
+ },
+ },
+ ],
+ };
+ const data = await AboutWelcomeDefaults.prepareContentForReact(
+ TEST_CONTENT
+ );
+ assert.propertyVal(data, "ua", "test");
+ assert.notPropertyVal(
+ data.screens[0].content.primary_button.action.data,
+ "source",
+ "test"
+ );
+ });
+ it("should remove theme screens on win7", async () => {
+ sandbox
+ .stub(global.AppConstants, "isPlatformAndVersionAtMost")
+ .returns(true);
+ sandbox
+ .stub(global.AWScreenUtils, "removeScreens")
+ .callsFake((screens, screen) =>
+ AWScreenUtils.removeScreens(screens, screen)
+ );
+
+ const { screens } = await AboutWelcomeDefaults.prepareContentForReact({
+ screens: [
+ {
+ content: {
+ tiles: { type: "theme" },
+ },
+ },
+ { id: "hello" },
+ {
+ content: {
+ tiles: { type: "theme" },
+ },
+ },
+ { id: "world" },
+ ],
+ });
+
+ assert.deepEqual(screens, [{ id: "hello" }, { id: "world" }]);
+ });
+ it("shouldn't remove colorway screens on win7", async () => {
+ sandbox
+ .stub(global.AppConstants, "isPlatformAndVersionAtMost")
+ .returns(true);
+ sandbox
+ .stub(global.AWScreenUtils, "removeScreens")
+ .callsFake((screens, screen) =>
+ AWScreenUtils.removeScreens(screens, screen)
+ );
+
+ const { screens } = await AboutWelcomeDefaults.prepareContentForReact({
+ screens: [
+ {
+ content: {
+ tiles: { type: "colorway" },
+ },
+ },
+ { id: "hello" },
+ {
+ content: {
+ tiles: { type: "theme" },
+ },
+ },
+ { id: "world" },
+ ],
+ });
+
+ assert.deepEqual(screens, [
+ {
+ content: {
+ tiles: { type: "colorway" },
+ },
+ },
+ { id: "hello" },
+ { id: "world" },
+ ]);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/aboutwelcome/MultiStageAboutWelcome.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAboutWelcome.test.jsx
new file mode 100644
index 0000000000..d017f94b7f
--- /dev/null
+++ b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAboutWelcome.test.jsx
@@ -0,0 +1,824 @@
+import { GlobalOverrider } from "test/unit/utils";
+import {
+ MultiStageAboutWelcome,
+ SecondaryCTA,
+ StepsIndicator,
+ ProgressBar,
+ WelcomeScreen,
+} from "content-src/aboutwelcome/components/MultiStageAboutWelcome";
+import { Themes } from "content-src/aboutwelcome/components/Themes";
+import React from "react";
+import { shallow, mount } from "enzyme";
+import { AboutWelcomeDefaults } from "aboutwelcome/lib/AboutWelcomeDefaults.jsm";
+import { AboutWelcomeUtils } from "content-src/lib/aboutwelcome-utils";
+
+describe("MultiStageAboutWelcome module", () => {
+ let globals;
+ let sandbox;
+
+ const DEFAULT_PROPS = {
+ defaultScreens: AboutWelcomeDefaults.getDefaults().screens,
+ metricsFlowUri: "http://localhost/",
+ message_id: "DEFAULT_ABOUTWELCOME",
+ utm_term: "default",
+ startScreen: 0,
+ };
+
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ globals.set({
+ AWGetSelectedTheme: () => Promise.resolve("automatic"),
+ AWSendEventTelemetry: () => {},
+ AWWaitForMigrationClose: () => Promise.resolve(),
+ AWSelectTheme: () => Promise.resolve(),
+ AWFinish: () => Promise.resolve(),
+ });
+ sandbox = sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("MultiStageAboutWelcome functional component", () => {
+ it("should render MultiStageAboutWelcome", () => {
+ const wrapper = shallow(<MultiStageAboutWelcome {...DEFAULT_PROPS} />);
+
+ assert.ok(wrapper.exists());
+ });
+
+ it("should pass activeTheme and initialTheme props to WelcomeScreen", async () => {
+ let wrapper = mount(<MultiStageAboutWelcome {...DEFAULT_PROPS} />);
+ // Spin the event loop to allow the useEffect hooks to execute,
+ // any promises to resolve, and re-rendering to happen after the
+ // promises have updated the state/props
+ await new Promise(resolve => setTimeout(resolve, 0));
+ // sync up enzyme's representation with the real DOM
+ wrapper.update();
+
+ let welcomeScreenWrapper = wrapper.find(WelcomeScreen);
+ assert.strictEqual(welcomeScreenWrapper.prop("activeTheme"), "automatic");
+ assert.strictEqual(
+ welcomeScreenWrapper.prop("initialTheme"),
+ "automatic"
+ );
+ });
+
+ it("should handle primary Action", () => {
+ const screens = [
+ {
+ content: {
+ title: "test title",
+ subtitle: "test subtitle",
+ primary_button: {
+ label: "Test button",
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ];
+
+ const PRIMARY_ACTION_PROPS = {
+ defaultScreens: screens,
+ metricsFlowUri: "http://localhost/",
+ message_id: "DEFAULT_ABOUTWELCOME",
+ utm_term: "default",
+ startScreen: 0,
+ };
+
+ const stub = sinon.stub(AboutWelcomeUtils, "sendActionTelemetry");
+ let wrapper = mount(<MultiStageAboutWelcome {...PRIMARY_ACTION_PROPS} />);
+ wrapper.update();
+
+ let welcomeScreenWrapper = wrapper.find(WelcomeScreen);
+ const btnPrimary = welcomeScreenWrapper.find(".primary");
+ btnPrimary.simulate("click");
+ assert.calledOnce(stub);
+ assert.equal(
+ stub.firstCall.args[0],
+ welcomeScreenWrapper.props().messageId
+ );
+ assert.equal(stub.firstCall.args[1], "primary_button");
+ stub.restore();
+ });
+
+ it("should autoAdvance on last screen and send appropriate telemetry", () => {
+ let clock = sinon.useFakeTimers();
+ const screens = [
+ {
+ auto_advance: "primary_button",
+ content: {
+ title: "test title",
+ subtitle: "test subtitle",
+ primary_button: {
+ label: "Test Button",
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ];
+ const AUTO_ADVANCE_PROPS = {
+ defaultScreens: screens,
+ metricsFlowUri: "http://localhost/",
+ message_id: "DEFAULT_ABOUTWELCOME",
+ utm_term: "default",
+ startScreen: 0,
+ };
+ const wrapper = mount(<MultiStageAboutWelcome {...AUTO_ADVANCE_PROPS} />);
+ wrapper.update();
+ const finishStub = sandbox.stub(global, "AWFinish");
+ const telemetryStub = sinon.stub(
+ AboutWelcomeUtils,
+ "sendActionTelemetry"
+ );
+
+ assert.notCalled(finishStub);
+ clock.tick(20001);
+ assert.calledOnce(finishStub);
+ assert.calledOnce(telemetryStub);
+ assert.equal(telemetryStub.lastCall.args[2], "AUTO_ADVANCE");
+ clock.restore();
+ finishStub.restore();
+ telemetryStub.restore();
+ });
+
+ it("should send telemetry ping on collectSelect", () => {
+ const screens = [
+ {
+ id: "EASY_SETUP_TEST",
+ content: {
+ tiles: {
+ type: "multiselect",
+ data: [
+ {
+ id: "checkbox-1",
+ defaultValue: true,
+ },
+ ],
+ },
+ primary_button: {
+ label: "Test Button",
+ action: {
+ collectSelect: true,
+ },
+ },
+ },
+ },
+ ];
+ const EASY_SETUP_PROPS = {
+ defaultScreens: screens,
+ message_id: "DEFAULT_ABOUTWELCOME",
+ startScreen: 0,
+ };
+ const stub = sinon.stub(AboutWelcomeUtils, "sendActionTelemetry");
+ let wrapper = mount(<MultiStageAboutWelcome {...EASY_SETUP_PROPS} />);
+ wrapper.update();
+
+ let welcomeScreenWrapper = wrapper.find(WelcomeScreen);
+ const btnPrimary = welcomeScreenWrapper.find(".primary");
+ btnPrimary.simulate("click");
+ assert.calledTwice(stub);
+ assert.equal(
+ stub.firstCall.args[0],
+ welcomeScreenWrapper.props().messageId
+ );
+ assert.equal(stub.firstCall.args[1], "primary_button");
+ assert.equal(
+ stub.lastCall.args[0],
+ welcomeScreenWrapper.props().messageId
+ );
+ assert.ok(stub.lastCall.args[1].includes("checkbox-1"));
+ assert.equal(stub.lastCall.args[2], "SELECT_CHECKBOX");
+ stub.restore();
+ });
+ });
+
+ describe("WelcomeScreen component", () => {
+ describe("get started screen", () => {
+ const screen = AboutWelcomeDefaults.getDefaults().screens.find(
+ s => s.id === "AW_PIN_FIREFOX"
+ );
+
+ const GET_STARTED_SCREEN_PROPS = {
+ id: screen.id,
+ totalNumberOfScreens: 1,
+ content: screen.content,
+ messageId: `${DEFAULT_PROPS.message_id}_${screen.id}`,
+ UTMTerm: DEFAULT_PROPS.utm_term,
+ flowParams: null,
+ };
+
+ it("should render GetStarted Screen", () => {
+ const wrapper = shallow(
+ <WelcomeScreen {...GET_STARTED_SCREEN_PROPS} />
+ );
+ assert.ok(wrapper.exists());
+ });
+
+ it("should render secondary.top button", () => {
+ let SCREEN_PROPS = {
+ content: {
+ title: "Step",
+ secondary_button_top: {
+ text: "test",
+ label: "test label",
+ },
+ },
+ position: "top",
+ };
+ const wrapper = mount(<SecondaryCTA {...SCREEN_PROPS} />);
+ assert.ok(wrapper.find("div.secondary-cta.top").exists());
+ });
+
+ it("should render the arrow icon in the secondary button", () => {
+ let SCREEN_PROPS = {
+ content: {
+ title: "Step",
+ secondary_button: {
+ has_arrow_icon: true,
+ label: "test label",
+ },
+ },
+ };
+ const wrapper = mount(<SecondaryCTA {...SCREEN_PROPS} />);
+ assert.ok(wrapper.find("button.arrow-icon").exists());
+ });
+
+ it("should render steps indicator", () => {
+ let PROPS = { totalNumberOfScreens: 1 };
+ const wrapper = mount(<StepsIndicator {...PROPS} />);
+ assert.ok(wrapper.find("div.indicator").exists());
+ });
+
+ it("should assign the total number of screens and current screen to the aria-valuemax and aria-valuenow labels", () => {
+ const EXTRA_PROPS = { totalNumberOfScreens: 3, order: 1 };
+ const wrapper = mount(
+ <WelcomeScreen {...GET_STARTED_SCREEN_PROPS} {...EXTRA_PROPS} />
+ );
+ const steps = wrapper.find(`div.steps`);
+ assert.ok(steps.exists());
+ const { attributes } = steps.getDOMNode();
+ assert.equal(
+ parseInt(attributes.getNamedItem("aria-valuemax").value, 10),
+ EXTRA_PROPS.totalNumberOfScreens
+ );
+ assert.equal(
+ parseInt(attributes.getNamedItem("aria-valuenow").value, 10),
+ EXTRA_PROPS.order + 1
+ );
+ });
+
+ it("should render progress bar", () => {
+ let SCREEN_PROPS = {
+ step: 1,
+ previousStep: 0,
+ totalNumberOfScreens: 2,
+ };
+ const wrapper = mount(<ProgressBar {...SCREEN_PROPS} />);
+ assert.ok(wrapper.find("div.indicator").exists());
+ assert.propertyVal(
+ wrapper.find("div.indicator").prop("style"),
+ "--progress-bar-progress",
+ "50%"
+ );
+ });
+
+ it("should have a primary, secondary and secondary.top button in the rendered input", () => {
+ const wrapper = mount(<WelcomeScreen {...GET_STARTED_SCREEN_PROPS} />);
+ assert.ok(wrapper.find(".primary").exists());
+ assert.ok(
+ wrapper
+ .find(".secondary-cta button.secondary[value='secondary_button']")
+ .exists()
+ );
+ assert.ok(
+ wrapper
+ .find(
+ ".secondary-cta.top button.secondary[value='secondary_button_top']"
+ )
+ .exists()
+ );
+ });
+ });
+
+ describe("theme screen", () => {
+ const THEME_SCREEN_PROPS = {
+ id: "test-theme-screen",
+ totalNumberOfScreens: 1,
+ content: {
+ title: "test title",
+ subtitle: "test subtitle",
+ tiles: {
+ type: "theme",
+ action: {
+ theme: "<event>",
+ },
+ data: [
+ {
+ theme: "automatic",
+ label: "test-label",
+ tooltip: "test-tooltip",
+ description: "test-description",
+ },
+ ],
+ },
+ primary_button: {
+ action: {},
+ label: "test button",
+ },
+ },
+ navigate: null,
+ messageId: `${DEFAULT_PROPS.message_id}_"test-theme-screen"`,
+ UTMTerm: DEFAULT_PROPS.utm_term,
+ flowParams: null,
+ activeTheme: "automatic",
+ };
+
+ it("should render WelcomeScreen", () => {
+ const wrapper = shallow(<WelcomeScreen {...THEME_SCREEN_PROPS} />);
+
+ assert.ok(wrapper.exists());
+ });
+
+ it("should check this.props.activeTheme in the rendered input", () => {
+ const wrapper = shallow(<Themes {...THEME_SCREEN_PROPS} />);
+
+ const selectedThemeInput = wrapper.find(".theme input[checked=true]");
+ assert.strictEqual(
+ selectedThemeInput.prop("value"),
+ THEME_SCREEN_PROPS.activeTheme
+ );
+ });
+ });
+ describe("import screen", () => {
+ const IMPORT_SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ subtitle: "test subtitle",
+ help_text: {
+ text: "test help text",
+ position: "default",
+ },
+ },
+ };
+ it("should render ImportScreen", () => {
+ const wrapper = mount(<WelcomeScreen {...IMPORT_SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+ });
+ it("should not have a primary or secondary button", () => {
+ const wrapper = mount(<WelcomeScreen {...IMPORT_SCREEN_PROPS} />);
+ assert.isFalse(wrapper.find(".primary").exists());
+ assert.isFalse(
+ wrapper.find(".secondary button[value='secondary_button']").exists()
+ );
+ assert.isFalse(
+ wrapper
+ .find(".secondary button[value='secondary_button_top']")
+ .exists()
+ );
+ });
+ });
+ describe("#handleAction", () => {
+ let SCREEN_PROPS;
+ let TEST_ACTION;
+ beforeEach(() => {
+ SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ subtitle: "test subtitle",
+ primary_button: {
+ action: {},
+ label: "test button",
+ },
+ },
+ navigate: sandbox.stub(),
+ setActiveTheme: sandbox.stub(),
+ UTMTerm: "you_tee_emm",
+ };
+ TEST_ACTION = SCREEN_PROPS.content.primary_button.action;
+ sandbox.stub(AboutWelcomeUtils, "handleUserAction").resolves();
+ });
+ it("should handle navigate", () => {
+ TEST_ACTION.navigate = true;
+ const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />);
+
+ wrapper.find(".primary").simulate("click");
+
+ assert.calledOnce(SCREEN_PROPS.navigate);
+ });
+ it("should handle theme", () => {
+ TEST_ACTION.theme = "test";
+ const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />);
+
+ wrapper.find(".primary").simulate("click");
+
+ assert.calledWith(SCREEN_PROPS.setActiveTheme, "test");
+ });
+ it("should handle dismiss", () => {
+ SCREEN_PROPS.content.dismiss_button = {
+ action: { dismiss: true },
+ };
+ const finishStub = sandbox.stub(global, "AWFinish");
+ const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />);
+
+ wrapper.find(".dismiss-button").simulate("click");
+
+ assert.calledOnce(finishStub);
+ });
+ it("should handle SHOW_FIREFOX_ACCOUNTS", () => {
+ TEST_ACTION.type = "SHOW_FIREFOX_ACCOUNTS";
+ const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />);
+
+ wrapper.find(".primary").simulate("click");
+
+ assert.calledWith(AboutWelcomeUtils.handleUserAction, {
+ data: {
+ extraParams: {
+ utm_campaign: "firstrun",
+ utm_medium: "referral",
+ utm_source: "activity-stream",
+ utm_term: "you_tee_emm-screen",
+ },
+ },
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ });
+ });
+ it("should handle SHOW_MIGRATION_WIZARD", () => {
+ TEST_ACTION.type = "SHOW_MIGRATION_WIZARD";
+ const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />);
+
+ wrapper.find(".primary").simulate("click");
+
+ assert.calledWith(AboutWelcomeUtils.handleUserAction, {
+ type: "SHOW_MIGRATION_WIZARD",
+ });
+ });
+ it("should handle SHOW_MIGRATION_WIZARD INSIDE MULTI_ACTION", async () => {
+ const migrationCloseStub = sandbox.stub(
+ global,
+ "AWWaitForMigrationClose"
+ );
+ const MULTI_ACTION_SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ subtitle: "test subtitle",
+ primary_button: {
+ action: {
+ type: "MULTI_ACTION",
+ navigate: true,
+ data: {
+ actions: [
+ {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ },
+ {
+ type: "SET_DEFAULT_BROWSER",
+ },
+ {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: {},
+ },
+ ],
+ },
+ },
+ label: "test button",
+ },
+ },
+ navigate: sandbox.stub(),
+ };
+ const wrapper = mount(<WelcomeScreen {...MULTI_ACTION_SCREEN_PROPS} />);
+
+ wrapper.find(".primary").simulate("click");
+ assert.calledWith(AboutWelcomeUtils.handleUserAction, {
+ type: "MULTI_ACTION",
+ navigate: true,
+ data: {
+ actions: [
+ {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ },
+ {
+ type: "SET_DEFAULT_BROWSER",
+ },
+ {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: {},
+ },
+ ],
+ },
+ });
+ // handleUserAction returns a Promise, so let's let the microtask queue
+ // flush so that anything waiting for the handleUserAction Promise to
+ // resolve can run.
+ await new Promise(resolve => queueMicrotask(resolve));
+ assert.calledOnce(migrationCloseStub);
+ });
+
+ it("should handle SHOW_MIGRATION_WIZARD INSIDE NESTED MULTI_ACTION", async () => {
+ const migrationCloseStub = sandbox.stub(
+ global,
+ "AWWaitForMigrationClose"
+ );
+ const MULTI_ACTION_SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ subtitle: "test subtitle",
+ primary_button: {
+ action: {
+ type: "MULTI_ACTION",
+ navigate: true,
+ data: {
+ actions: [
+ {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ },
+ {
+ type: "SET_DEFAULT_BROWSER",
+ },
+ {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ },
+ {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: {},
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ label: "test button",
+ },
+ },
+ navigate: sandbox.stub(),
+ };
+ const wrapper = mount(<WelcomeScreen {...MULTI_ACTION_SCREEN_PROPS} />);
+
+ wrapper.find(".primary").simulate("click");
+ assert.calledWith(AboutWelcomeUtils.handleUserAction, {
+ type: "MULTI_ACTION",
+ navigate: true,
+ data: {
+ actions: [
+ {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ },
+ {
+ type: "SET_DEFAULT_BROWSER",
+ },
+ {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ },
+ {
+ type: "SHOW_MIGRATION_WIZARD",
+ data: {},
+ },
+ ],
+ },
+ },
+ ],
+ },
+ });
+ // handleUserAction returns a Promise, so let's let the microtask queue
+ // flush so that anything waiting for the handleUserAction Promise to
+ // resolve can run.
+ await new Promise(resolve => queueMicrotask(resolve));
+ assert.calledOnce(migrationCloseStub);
+ });
+ it("should unset prefs from unchecked checkboxes", () => {
+ const PREF_SCREEN_PROPS = {
+ content: {
+ title: "Checkboxes",
+ tiles: {
+ type: "multiselect",
+ data: [
+ {
+ id: "checkbox-1",
+ label: "checkbox 1",
+ checkedAction: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "pref-a",
+ value: true,
+ },
+ },
+ },
+ uncheckedAction: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "pref-a",
+ },
+ },
+ },
+ },
+ {
+ id: "checkbox-2",
+ label: "checkbox 2",
+ checkedAction: {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "pref-b",
+ value: "pref-b",
+ },
+ },
+ },
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "pref-c",
+ value: 3,
+ },
+ },
+ },
+ ],
+ },
+ },
+ uncheckedAction: {
+ type: "SET_PREF",
+ data: {
+ pref: { name: "pref-b" },
+ },
+ },
+ },
+ ],
+ },
+ primary_button: {
+ label: "Set Prefs",
+ action: {
+ type: "MULTI_ACTION",
+ collectSelect: true,
+ isDynamic: true,
+ navigate: true,
+ data: {
+ actions: [],
+ },
+ },
+ },
+ },
+ navigate: sandbox.stub(),
+ setActiveMultiSelect: sandbox.stub(),
+ };
+
+ // No checkboxes checked. All prefs will be unset and pref-c will not be
+ // reset.
+ {
+ const wrapper = mount(
+ <WelcomeScreen {...PREF_SCREEN_PROPS} activeMultiSelect={[]} />
+ );
+ wrapper.find(".primary").simulate("click");
+ assert.calledWith(AboutWelcomeUtils.handleUserAction, {
+ type: "MULTI_ACTION",
+ collectSelect: true,
+ isDynamic: true,
+ navigate: true,
+ data: {
+ actions: [
+ { type: "SET_PREF", data: { pref: { name: "pref-a" } } },
+ { type: "SET_PREF", data: { pref: { name: "pref-b" } } },
+ ],
+ },
+ });
+
+ AboutWelcomeUtils.handleUserAction.resetHistory();
+ }
+
+ // The first checkbox is checked. Only pref-a will be set and pref-c
+ // will not be reset.
+ {
+ const wrapper = mount(
+ <WelcomeScreen
+ {...PREF_SCREEN_PROPS}
+ activeMultiSelect={["checkbox-1"]}
+ />
+ );
+ wrapper.find(".primary").simulate("click");
+ assert.calledWith(AboutWelcomeUtils.handleUserAction, {
+ type: "MULTI_ACTION",
+ collectSelect: true,
+ isDynamic: true,
+ navigate: true,
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "pref-a",
+ value: true,
+ },
+ },
+ },
+ { type: "SET_PREF", data: { pref: { name: "pref-b" } } },
+ ],
+ },
+ });
+
+ AboutWelcomeUtils.handleUserAction.resetHistory();
+ }
+
+ // The second checkbox is checked. Prefs pref-b and pref-c will be set.
+ {
+ const wrapper = mount(
+ <WelcomeScreen
+ {...PREF_SCREEN_PROPS}
+ activeMultiSelect={["checkbox-2"]}
+ />
+ );
+ wrapper.find(".primary").simulate("click");
+ assert.calledWith(AboutWelcomeUtils.handleUserAction, {
+ type: "MULTI_ACTION",
+ collectSelect: true,
+ isDynamic: true,
+ navigate: true,
+ data: {
+ actions: [
+ { type: "SET_PREF", data: { pref: { name: "pref-a" } } },
+ {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: { pref: { name: "pref-b", value: "pref-b" } },
+ },
+ {
+ type: "SET_PREF",
+ data: { pref: { name: "pref-c", value: 3 } },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ });
+
+ AboutWelcomeUtils.handleUserAction.resetHistory();
+ }
+
+ // // Both checkboxes are checked. All prefs will be set.
+ {
+ const wrapper = mount(
+ <WelcomeScreen
+ {...PREF_SCREEN_PROPS}
+ activeMultiSelect={["checkbox-1", "checkbox-2"]}
+ />
+ );
+ wrapper.find(".primary").simulate("click");
+ assert.calledWith(AboutWelcomeUtils.handleUserAction, {
+ type: "MULTI_ACTION",
+ collectSelect: true,
+ isDynamic: true,
+ navigate: true,
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: { pref: { name: "pref-a", value: true } },
+ },
+ {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: { pref: { name: "pref-b", value: "pref-b" } },
+ },
+ {
+ type: "SET_PREF",
+ data: { pref: { name: "pref-c", value: 3 } },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ });
+
+ AboutWelcomeUtils.handleUserAction.resetHistory();
+ }
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/aboutwelcome/OnboardingVideoTest.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/OnboardingVideoTest.test.jsx
new file mode 100644
index 0000000000..db6d8ba10a
--- /dev/null
+++ b/browser/components/newtab/test/unit/aboutwelcome/OnboardingVideoTest.test.jsx
@@ -0,0 +1,45 @@
+import React from "react";
+import { mount } from "enzyme";
+import { OnboardingVideo } from "content-src/aboutwelcome/components/OnboardingVideo";
+
+describe("OnboardingVideo component", () => {
+ let sandbox;
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ const SCREEN_PROPS = {
+ content: {
+ title: "Test title",
+ video_container: {
+ video_url: "test url",
+ },
+ },
+ };
+
+ it("should handle video_start action when video is played", () => {
+ const handleAction = sandbox.stub();
+ const wrapper = mount(
+ <OnboardingVideo handleAction={handleAction} {...SCREEN_PROPS} />
+ );
+ wrapper.find("video").simulate("play");
+ assert.calledWith(handleAction, {
+ currentTarget: { value: "video_start" },
+ });
+ });
+ it("should handle video_end action when video has completed playing", () => {
+ const handleAction = sandbox.stub();
+ const wrapper = mount(
+ <OnboardingVideo handleAction={handleAction} {...SCREEN_PROPS} />
+ );
+ wrapper.find("video").simulate("ended");
+ assert.calledWith(handleAction, {
+ currentTarget: { value: "video_end" },
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/ASRouter.test.js b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js
new file mode 100644
index 0000000000..732200b408
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js
@@ -0,0 +1,3040 @@
+import { _ASRouter, MessageLoaderUtils } from "lib/ASRouter.jsm";
+import { QueryCache } from "lib/ASRouterTargeting.jsm";
+import {
+ FAKE_LOCAL_MESSAGES,
+ FAKE_LOCAL_PROVIDER,
+ FAKE_LOCAL_PROVIDERS,
+ FAKE_REMOTE_MESSAGES,
+ FAKE_REMOTE_PROVIDER,
+ FAKE_REMOTE_SETTINGS_PROVIDER,
+} from "./constants";
+import {
+ ASRouterPreferences,
+ TARGETING_PREFERENCES,
+} from "lib/ASRouterPreferences.jsm";
+import { ASRouterTriggerListeners } from "lib/ASRouterTriggerListeners.jsm";
+import { CFRPageActions } from "lib/CFRPageActions.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs";
+import ProviderResponseSchema from "content-src/asrouter/schemas/provider-response.schema.json";
+import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs";
+
+const MESSAGE_PROVIDER_PREF_NAME =
+ "browser.newtabpage.activity-stream.asrouter.providers.snippets";
+const FAKE_PROVIDERS = [
+ FAKE_LOCAL_PROVIDER,
+ FAKE_REMOTE_PROVIDER,
+ FAKE_REMOTE_SETTINGS_PROVIDER,
+];
+const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
+const FAKE_RESPONSE_HEADERS = { get() {} };
+const FAKE_BUNDLE = [FAKE_LOCAL_MESSAGES[1], FAKE_LOCAL_MESSAGES[2]];
+
+const USE_REMOTE_L10N_PREF =
+ "browser.newtabpage.activity-stream.asrouter.useRemoteL10n";
+
+// eslint-disable-next-line max-statements
+describe("ASRouter", () => {
+ let Router;
+ let globals;
+ let sandbox;
+ let initParams;
+ let messageBlockList;
+ let providerBlockList;
+ let messageImpressions;
+ let groupImpressions;
+ let previousSessionEnd;
+ let fetchStub;
+ let clock;
+ let getStringPrefStub;
+ let fakeAttributionCode;
+ let fakeTargetingContext;
+ let FakeToolbarBadgeHub;
+ let FakeToolbarPanelHub;
+ let FakeMomentsPageHub;
+ let ASRouterTargeting;
+ let screenImpressions;
+
+ function setMessageProviderPref(value) {
+ sandbox.stub(ASRouterPreferences, "providers").get(() => value);
+ }
+
+ function initASRouter(router) {
+ const getStub = sandbox.stub();
+ getStub.returns(Promise.resolve());
+ getStub
+ .withArgs("messageBlockList")
+ .returns(Promise.resolve(messageBlockList));
+ getStub
+ .withArgs("providerBlockList")
+ .returns(Promise.resolve(providerBlockList));
+ getStub
+ .withArgs("messageImpressions")
+ .returns(Promise.resolve(messageImpressions));
+ getStub.withArgs("groupImpressions").resolves(groupImpressions);
+ getStub
+ .withArgs("previousSessionEnd")
+ .returns(Promise.resolve(previousSessionEnd));
+ getStub
+ .withArgs("screenImpressions")
+ .returns(Promise.resolve(screenImpressions));
+ initParams = {
+ storage: {
+ get: getStub,
+ set: sandbox.stub().returns(Promise.resolve()),
+ },
+ sendTelemetry: sandbox.stub().resolves(),
+ clearChildMessages: sandbox.stub().resolves(),
+ clearChildProviders: sandbox.stub().resolves(),
+ updateAdminState: sandbox.stub().resolves(),
+ dispatchCFRAction: sandbox.stub().resolves(),
+ };
+ sandbox.stub(router, "loadMessagesFromAllProviders").callThrough();
+ return router.init(initParams);
+ }
+
+ async function createRouterAndInit(providers = FAKE_PROVIDERS) {
+ setMessageProviderPref(providers);
+ // `.freeze` to catch any attempts at modifying the object
+ Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
+ await initASRouter(Router);
+ }
+
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ messageBlockList = [];
+ providerBlockList = [];
+ messageImpressions = {};
+ groupImpressions = {};
+ previousSessionEnd = 100;
+ screenImpressions = {};
+ sandbox = sinon.createSandbox();
+ ASRouterTargeting = {
+ isMatch: sandbox.stub(),
+ findMatchingMessage: sandbox.stub(),
+ Environment: {
+ locale: "en-US",
+ localeLanguageCode: "en",
+ browserSettings: {
+ update: {
+ channel: "default",
+ enabled: true,
+ autoDownload: true,
+ },
+ },
+ attributionData: {},
+ currentDate: "2000-01-01T10:00:0.001Z",
+ profileAgeCreated: {},
+ profileAgeReset: {},
+ usesFirefoxSync: false,
+ isFxAEnabled: true,
+ isFxASignedIn: false,
+ sync: {
+ desktopDevices: 0,
+ mobileDevices: 0,
+ totalDevices: 0,
+ },
+ xpinstallEnabled: true,
+ addonsInfo: {},
+ searchEngines: {},
+ isDefaultBrowser: false,
+ devToolsOpenedCount: 5,
+ topFrecentSites: {},
+ recentBookmarks: {},
+ pinnedSites: [
+ {
+ url: "https://amazon.com",
+ host: "amazon.com",
+ searchTopSite: true,
+ },
+ ],
+ providerCohorts: {
+ onboarding: "",
+ cfr: "",
+ "message-groups": "",
+ "messaging-experiments": "",
+ snippets: "",
+ "whats-new-panel": "",
+ },
+ totalBookmarksCount: {},
+ firefoxVersion: 80,
+ region: "US",
+ needsUpdate: {},
+ hasPinnedTabs: false,
+ hasAccessedFxAPanel: false,
+ isWhatsNewPanelEnabled: true,
+ userPrefs: {
+ cfrFeatures: true,
+ cfrAddons: true,
+ snippets: true,
+ },
+ totalBlockedCount: {},
+ blockedCountByType: {},
+ attachedFxAOAuthClients: [],
+ platformName: "macosx",
+ scores: {},
+ scoreThreshold: 5000,
+ isChinaRepack: false,
+ userId: "adsf",
+ },
+ };
+
+ ASRouterPreferences.specialConditions = {
+ someCondition: true,
+ };
+ sandbox.spy(ASRouterPreferences, "init");
+ sandbox.spy(ASRouterPreferences, "uninit");
+ sandbox.spy(ASRouterPreferences, "addListener");
+ sandbox.spy(ASRouterPreferences, "removeListener");
+
+ clock = sandbox.useFakeTimers();
+ fetchStub = sandbox
+ .stub(global, "fetch")
+ .withArgs("http://fake.com/endpoint")
+ .resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ messages: FAKE_REMOTE_MESSAGES }),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
+
+ fakeAttributionCode = {
+ allowedCodeKeys: ["foo", "bar", "baz"],
+ _clearCache: () => sinon.stub(),
+ getAttrDataAsync: () => Promise.resolve({ content: "addonID" }),
+ deleteFileAsync: () => Promise.resolve(),
+ writeAttributionFile: () => Promise.resolve(),
+ getCachedAttributionData: sinon.stub(),
+ };
+ FakeToolbarPanelHub = {
+ init: sandbox.stub(),
+ uninit: sandbox.stub(),
+ forceShowMessage: sandbox.stub(),
+ enableToolbarButton: sandbox.stub(),
+ };
+ FakeToolbarBadgeHub = {
+ init: sandbox.stub(),
+ uninit: sandbox.stub(),
+ registerBadgeNotificationListener: sandbox.stub(),
+ };
+ FakeMomentsPageHub = {
+ init: sandbox.stub(),
+ uninit: sandbox.stub(),
+ executeAction: sandbox.stub(),
+ };
+ fakeTargetingContext = {
+ combineContexts: sandbox.stub(),
+ evalWithDefault: sandbox.stub().resolves(),
+ };
+ let fakeNimbusFeatures = [
+ "cfr",
+ "infobar",
+ "spotlight",
+ "moments-page",
+ "pbNewtab",
+ ].reduce((features, featureId) => {
+ features[featureId] = {
+ getAllVariables: sandbox.stub().returns(null),
+ recordExposureEvent: sandbox.stub(),
+ };
+ return features;
+ }, {});
+ globals.set({
+ // Testing framework doesn't know how to `defineLazyModuleGetter` so we're
+ // importing these modules into the global scope ourselves.
+ GroupsConfigurationProvider: { getMessages: () => Promise.resolve([]) },
+ ASRouterPreferences,
+ TARGETING_PREFERENCES,
+ ASRouterTargeting,
+ ASRouterTriggerListeners,
+ QueryCache,
+ gBrowser: { selectedBrowser: {} },
+ gURLBar: {},
+ isSeparateAboutWelcome: true,
+ AttributionCode: fakeAttributionCode,
+ SnippetsTestMessageProvider,
+ PanelTestProvider,
+ MacAttribution: { applicationPath: "" },
+ ToolbarBadgeHub: FakeToolbarBadgeHub,
+ ToolbarPanelHub: FakeToolbarPanelHub,
+ MomentsPageHub: FakeMomentsPageHub,
+ KintoHttpClient: class {
+ bucket() {
+ return this;
+ }
+ collection() {
+ return this;
+ }
+ getRecord() {
+ return Promise.resolve({ data: {} });
+ }
+ },
+ Downloader: class {
+ download() {
+ return Promise.resolve("/path/to/download");
+ }
+ },
+ NimbusFeatures: fakeNimbusFeatures,
+ ExperimentAPI: {
+ getExperimentMetaData: sandbox.stub().returns({
+ slug: "experiment-slug",
+ active: true,
+ branch: { slug: "experiment-branch-slug" },
+ }),
+ getExperiment: sandbox.stub().returns({
+ branch: {
+ slug: "unit-slug",
+ feature: {
+ featureId: "foo",
+ value: { id: "test-message" },
+ },
+ },
+ }),
+ getAllBranches: sandbox.stub().resolves([]),
+ ready: sandbox.stub().resolves(),
+ },
+ SpecialMessageActions: {
+ handleAction: sandbox.stub(),
+ },
+ TargetingContext: class {
+ static combineContexts(...args) {
+ return fakeTargetingContext.combineContexts.apply(sandbox, args);
+ }
+
+ evalWithDefault(expr) {
+ return fakeTargetingContext.evalWithDefault(expr);
+ }
+ },
+ RemoteL10n: {
+ // This is just a subset of supported locales that happen to be used in
+ // the test.
+ isLocaleSupported: locale => ["en-US", "ja-JP-mac"].includes(locale),
+ },
+ });
+ await createRouterAndInit();
+ });
+ afterEach(() => {
+ Router.uninit();
+ ASRouterPreferences.uninit();
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe(".state", () => {
+ it("should throw if an attempt to set .state was made", () => {
+ assert.throws(() => {
+ Router.state = {};
+ });
+ });
+ });
+
+ describe("#init", () => {
+ it("should only be called once", async () => {
+ Router = new _ASRouter();
+ let state = await initASRouter(Router);
+
+ assert.equal(state, Router.state);
+
+ assert.isNull(await Router.init({}));
+ });
+ it("should only be called once", async () => {
+ Router = new _ASRouter();
+ initASRouter(Router);
+ let secondCall = await Router.init({});
+
+ assert.isNull(
+ secondCall,
+ "Should not init twice, it should exit early with null"
+ );
+ });
+ it("should set state.messageBlockList to the block list in persistent storage", async () => {
+ messageBlockList = ["foo"];
+ Router = new _ASRouter();
+ await initASRouter(Router);
+
+ assert.deepEqual(Router.state.messageBlockList, ["foo"]);
+ });
+ it("should initialize all the hub providers", async () => {
+ // ASRouter init called in `beforeEach` block above
+
+ assert.calledOnce(FakeToolbarBadgeHub.init);
+ assert.calledOnce(FakeToolbarPanelHub.init);
+ assert.calledOnce(FakeMomentsPageHub.init);
+
+ assert.calledWithExactly(
+ FakeToolbarBadgeHub.init,
+ Router.waitForInitialized,
+ {
+ handleMessageRequest: Router.handleMessageRequest,
+ addImpression: Router.addImpression,
+ blockMessageById: Router.blockMessageById,
+ sendTelemetry: Router.sendTelemetry,
+ unblockMessageById: Router.unblockMessageById,
+ }
+ );
+
+ assert.calledWithExactly(
+ FakeToolbarPanelHub.init,
+ Router.waitForInitialized,
+ {
+ getMessages: Router.handleMessageRequest,
+ sendTelemetry: Router.sendTelemetry,
+ }
+ );
+
+ assert.calledWithExactly(
+ FakeMomentsPageHub.init,
+ Router.waitForInitialized,
+ {
+ handleMessageRequest: Router.handleMessageRequest,
+ addImpression: Router.addImpression,
+ blockMessageById: Router.blockMessageById,
+ sendTelemetry: Router.sendTelemetry,
+ }
+ );
+ });
+ it("should set state.messageImpressions to the messageImpressions object in persistent storage", async () => {
+ // Note that messageImpressions are only kept if a message exists in router and has a .frequency property,
+ // otherwise they will be cleaned up by .cleanupImpressions()
+ const testMessage = { id: "foo", frequency: { lifetimeCap: 10 } };
+ messageImpressions = { foo: [0, 1, 2] };
+ setMessageProviderPref([
+ { id: "onboarding", type: "local", messages: [testMessage] },
+ ]);
+ Router = new _ASRouter();
+ await initASRouter(Router);
+
+ assert.deepEqual(Router.state.messageImpressions, messageImpressions);
+ });
+ it("should set state.screenImpressions to the screenImpressions object in persistent storage", async () => {
+ screenImpressions = { test: 123 };
+
+ Router = new _ASRouter();
+ await initASRouter(Router);
+
+ assert.deepEqual(Router.state.screenImpressions, screenImpressions);
+ });
+ it("should clear impressions for groups that are not active", async () => {
+ groupImpressions = { foo: [0, 1, 2] };
+ Router = new _ASRouter();
+ await initASRouter(Router);
+
+ assert.notProperty(Router.state.groupImpressions, "foo");
+ });
+ it("should keep impressions for groups that are active", async () => {
+ Router = new _ASRouter();
+ await initASRouter(Router);
+ await Router.setState(() => {
+ return {
+ groups: [
+ {
+ id: "foo",
+ enabled: true,
+ frequency: {
+ custom: [{ period: ONE_DAY_IN_MS, cap: 10 }],
+ lifetime: Infinity,
+ },
+ },
+ ],
+ groupImpressions: { foo: [Date.now()] },
+ };
+ });
+ Router.cleanupImpressions();
+
+ assert.property(Router.state.groupImpressions, "foo");
+ assert.lengthOf(Router.state.groupImpressions.foo, 1);
+ });
+ it("should remove old impressions for a group", async () => {
+ Router = new _ASRouter();
+ await initASRouter(Router);
+ await Router.setState(() => {
+ return {
+ groups: [
+ {
+ id: "foo",
+ enabled: true,
+ frequency: {
+ custom: [{ period: ONE_DAY_IN_MS, cap: 10 }],
+ },
+ },
+ ],
+ groupImpressions: {
+ foo: [Date.now() - ONE_DAY_IN_MS - 1, Date.now()],
+ },
+ };
+ });
+ Router.cleanupImpressions();
+
+ assert.property(Router.state.groupImpressions, "foo");
+ assert.lengthOf(Router.state.groupImpressions.foo, 1);
+ });
+ it("should await .loadMessagesFromAllProviders() and add messages from providers to state.messages", async () => {
+ Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
+
+ await initASRouter(Router);
+
+ assert.calledOnce(Router.loadMessagesFromAllProviders);
+ assert.isArray(Router.state.messages);
+ assert.lengthOf(
+ Router.state.messages,
+ FAKE_LOCAL_MESSAGES.length + FAKE_REMOTE_MESSAGES.length
+ );
+ });
+ it("should load additional allowed hosts", async () => {
+ getStringPrefStub.returns('["allow.com"]');
+ await createRouterAndInit();
+
+ assert.propertyVal(Router.ALLOWLIST_HOSTS, "allow.com", "preview");
+ // Should still include the defaults
+ assert.lengthOf(Object.keys(Router.ALLOWLIST_HOSTS), 3);
+ });
+ it("should fallback to defaults if pref parsing fails", async () => {
+ getStringPrefStub.returns("err");
+ await createRouterAndInit();
+
+ assert.lengthOf(Object.keys(Router.ALLOWLIST_HOSTS), 2);
+ assert.propertyVal(
+ Router.ALLOWLIST_HOSTS,
+ "snippets-admin.mozilla.org",
+ "preview"
+ );
+ assert.propertyVal(
+ Router.ALLOWLIST_HOSTS,
+ "activity-stream-icons.services.mozilla.com",
+ "production"
+ );
+ });
+ it("should set state.previousSessionEnd from IndexedDB", async () => {
+ previousSessionEnd = 200;
+ await createRouterAndInit();
+
+ assert.equal(Router.state.previousSessionEnd, previousSessionEnd);
+ });
+ it("should assign ASRouterPreferences.specialConditions to state", async () => {
+ assert.isTrue(ASRouterPreferences.specialConditions.someCondition);
+ assert.isTrue(Router.state.someCondition);
+ });
+ it("should add observer for `intl:app-locales-changed`", async () => {
+ sandbox.spy(global.Services.obs, "addObserver");
+ await createRouterAndInit();
+
+ assert.calledWithExactly(
+ global.Services.obs.addObserver,
+ Router._onLocaleChanged,
+ "intl:app-locales-changed"
+ );
+ });
+ it("should add a pref observer", async () => {
+ sandbox.spy(global.Services.prefs, "addObserver");
+ await createRouterAndInit();
+
+ assert.calledOnce(global.Services.prefs.addObserver);
+ assert.calledWithExactly(
+ global.Services.prefs.addObserver,
+ USE_REMOTE_L10N_PREF,
+ Router
+ );
+ });
+ describe("lazily loading local test providers", () => {
+ afterEach(() => {
+ Router.uninit();
+ });
+ it("should add the local test providers on init if devtools are enabled", async () => {
+ sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true);
+
+ await createRouterAndInit();
+
+ assert.property(Router._localProviders, "SnippetsTestMessageProvider");
+ assert.property(Router._localProviders, "PanelTestProvider");
+ });
+ it("should not add the local test providers on init if devtools are disabled", async () => {
+ sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false);
+
+ await createRouterAndInit();
+
+ assert.notProperty(
+ Router._localProviders,
+ "SnippetsTestMessageProvider"
+ );
+ assert.notProperty(Router._localProviders, "PanelTestProvider");
+ });
+ });
+ });
+
+ describe("preference changes", () => {
+ it("should call ASRouterPreferences.init and add a listener on init", () => {
+ assert.calledOnce(ASRouterPreferences.init);
+ assert.calledWith(ASRouterPreferences.addListener, Router.onPrefChange);
+ });
+ it("should call ASRouterPreferences.uninit and remove the listener on uninit", () => {
+ Router.uninit();
+ assert.calledOnce(ASRouterPreferences.uninit);
+ assert.calledWith(
+ ASRouterPreferences.removeListener,
+ Router.onPrefChange
+ );
+ });
+ it("should send a AS_ROUTER_TARGETING_UPDATE message", async () => {
+ const messageTargeted = {
+ id: "1",
+ campaign: "foocampaign",
+ targeting: "true",
+ groups: ["snippets"],
+ provider: "snippets",
+ };
+ const messageNotTargeted = {
+ id: "2",
+ campaign: "foocampaign",
+ groups: ["snippets"],
+ provider: "snippets",
+ };
+ await Router.setState({
+ messages: [messageTargeted, messageNotTargeted],
+ providers: [{ id: "snippets" }],
+ });
+ fakeTargetingContext.evalWithDefault.resolves(false);
+
+ await Router.onPrefChange("services.sync.username");
+
+ assert.calledOnce(initParams.clearChildMessages);
+ assert.calledWith(initParams.clearChildMessages, [messageTargeted.id]);
+ });
+ it("should call loadMessagesFromAllProviders on pref change", () => {
+ ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME);
+ assert.calledOnce(Router.loadMessagesFromAllProviders);
+ });
+ it("should update groups state if a user pref changes", async () => {
+ await Router.setState({
+ groups: [{ id: "foo", userPreferences: ["bar"], enabled: true }],
+ });
+ sandbox.stub(ASRouterPreferences, "getUserPreference");
+
+ await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME);
+
+ assert.calledWithExactly(ASRouterPreferences.getUserPreference, "bar");
+ });
+ it("should update the list of providers on pref change", async () => {
+ const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, {
+ url: "baz.com",
+ });
+ setMessageProviderPref([
+ FAKE_LOCAL_PROVIDER,
+ modifiedRemoteProvider,
+ FAKE_REMOTE_SETTINGS_PROVIDER,
+ ]);
+
+ const { length } = Router.state.providers;
+
+ ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME);
+ await Router._updateMessageProviders();
+
+ const provider = Router.state.providers.find(p => p.url === "baz.com");
+ assert.lengthOf(Router.state.providers, length);
+ assert.isDefined(provider);
+ });
+ it("should clear disabled providers on pref change", async () => {
+ const TEST_PROVIDER_ID = "some_provider_id";
+ await Router.setState({
+ providers: [{ id: TEST_PROVIDER_ID }],
+ });
+ const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, {
+ id: TEST_PROVIDER_ID,
+ enabled: false,
+ });
+ setMessageProviderPref([
+ FAKE_LOCAL_PROVIDER,
+ modifiedRemoteProvider,
+ FAKE_REMOTE_SETTINGS_PROVIDER,
+ ]);
+ await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME);
+
+ assert.calledOnce(initParams.clearChildProviders);
+ assert.calledWith(initParams.clearChildProviders, [TEST_PROVIDER_ID]);
+ });
+ });
+
+ describe("setState", () => {
+ it("should broadcast a message to update the admin tool on a state change if the asrouter.devtoolsEnabled pref is", async () => {
+ sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true);
+ sandbox.stub(Router, "getTargetingParameters").resolves({});
+ const state = await Router.setState({ foo: 123 });
+
+ assert.calledOnce(initParams.updateAdminState);
+ assert.deepEqual(state.providerPrefs, ASRouterPreferences.providers);
+ assert.deepEqual(
+ state.userPrefs,
+ ASRouterPreferences.getAllUserPreferences()
+ );
+ assert.deepEqual(state.targetingParameters, {});
+ assert.deepEqual(state.errors, Router.errors);
+ });
+ it("should not send a message on a state change asrouter.devtoolsEnabled pref is on", async () => {
+ sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false);
+ await Router.setState({ foo: 123 });
+
+ assert.notCalled(initParams.updateAdminState);
+ });
+ });
+
+ describe("getTargetingParameters", () => {
+ it("should return the targeting parameters", async () => {
+ const stub = sandbox.stub().resolves("foo");
+ const obj = { foo: 1 };
+ sandbox.stub(obj, "foo").get(stub);
+ const result = await Router.getTargetingParameters(obj, obj);
+
+ assert.calledTwice(stub);
+ assert.propertyVal(result, "foo", "foo");
+ });
+ });
+
+ describe("evaluateExpression", () => {
+ it("should call ASRouterTargeting to evaluate", async () => {
+ fakeTargetingContext.evalWithDefault.resolves("foo");
+ const response = await Router.evaluateExpression({});
+ assert.equal(response.evaluationStatus.result, "foo");
+ assert.isTrue(response.evaluationStatus.success);
+ });
+ it("should catch evaluation errors", async () => {
+ fakeTargetingContext.evalWithDefault.returns(
+ Promise.reject(new Error("fake error"))
+ );
+ const response = await Router.evaluateExpression({});
+ assert.isFalse(response.evaluationStatus.success);
+ });
+ });
+
+ describe("#routeCFRMessage", () => {
+ let browser;
+ beforeEach(() => {
+ sandbox.stub(CFRPageActions, "forceRecommendation");
+ sandbox.stub(CFRPageActions, "addRecommendation");
+ browser = {};
+ });
+ it("should route whatsnew_panel_message message to the right hub", () => {
+ Router.routeCFRMessage(
+ { template: "whatsnew_panel_message" },
+ browser,
+ "",
+ true
+ );
+
+ assert.calledOnce(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route moments messages to the right hub", () => {
+ Router.routeCFRMessage({ template: "update_action" }, browser, "", true);
+
+ assert.calledOnce(FakeMomentsPageHub.executeAction);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ });
+ it("should route toolbar_badge message to the right hub", () => {
+ Router.routeCFRMessage({ template: "toolbar_badge" }, browser);
+
+ assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route milestone_message to the right hub", () => {
+ Router.routeCFRMessage(
+ { template: "milestone_message" },
+ browser,
+ "",
+ false
+ );
+
+ assert.calledOnce(CFRPageActions.addRecommendation);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route cfr_doorhanger message to the right hub force = false", () => {
+ Router.routeCFRMessage(
+ { template: "cfr_doorhanger" },
+ browser,
+ { param: {} },
+ false
+ );
+
+ assert.calledOnce(CFRPageActions.addRecommendation);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route cfr_doorhanger message to the right hub force = true", () => {
+ Router.routeCFRMessage({ template: "cfr_doorhanger" }, browser, {}, true);
+
+ assert.calledOnce(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route cfr_urlbar_chiclet message to the right hub force = false", () => {
+ Router.routeCFRMessage(
+ { template: "cfr_urlbar_chiclet" },
+ browser,
+ { param: {} },
+ false
+ );
+
+ assert.calledOnce(CFRPageActions.addRecommendation);
+ const { args } = CFRPageActions.addRecommendation.firstCall;
+ // Host should be null
+ assert.isNull(args[1]);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route cfr_urlbar_chiclet message to the right hub force = true", () => {
+ Router.routeCFRMessage(
+ { template: "cfr_urlbar_chiclet" },
+ browser,
+ {},
+ true
+ );
+
+ assert.calledOnce(CFRPageActions.forceRecommendation);
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ it("should route default to sending to content", () => {
+ Router.routeCFRMessage({ template: "snippets" }, browser, {}, true);
+
+ assert.notCalled(FakeToolbarPanelHub.forceShowMessage);
+ assert.notCalled(CFRPageActions.forceRecommendation);
+ assert.notCalled(CFRPageActions.addRecommendation);
+ assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+ assert.notCalled(FakeMomentsPageHub.executeAction);
+ });
+ });
+
+ describe("#loadMessagesFromAllProviders", () => {
+ function assertRouterContainsMessages(messages) {
+ const messageIdsInRouter = Router.state.messages.map(m => m.id);
+ for (const message of messages) {
+ assert.include(messageIdsInRouter, message.id);
+ }
+ }
+
+ it("should not trigger an update if not enough time has passed for a provider", async () => {
+ await createRouterAndInit([
+ {
+ id: "remotey",
+ type: "remote",
+ enabled: true,
+ url: "http://fake.com/endpoint",
+ updateCycleInMs: 300,
+ },
+ ]);
+
+ const previousState = Router.state;
+
+ // Since we've previously gotten messages during init and we haven't advanced our fake timer,
+ // no updates should be triggered.
+ await Router.loadMessagesFromAllProviders();
+ assert.deepEqual(Router.state, previousState);
+ });
+ it("should not trigger an update if we only have local providers", async () => {
+ await createRouterAndInit([
+ {
+ id: "foo",
+ type: "local",
+ enabled: true,
+ messages: FAKE_LOCAL_MESSAGES,
+ },
+ ]);
+
+ const previousState = Router.state;
+ const stub = sandbox.stub(MessageLoaderUtils, "loadMessagesForProvider");
+
+ clock.tick(300);
+
+ await Router.loadMessagesFromAllProviders();
+
+ assert.deepEqual(Router.state, previousState);
+ assert.notCalled(stub);
+ });
+ it("should update messages for a provider if enough time has passed, without removing messages for other providers", async () => {
+ const NEW_MESSAGES = [{ id: "new_123" }];
+ await createRouterAndInit([
+ {
+ id: "remotey",
+ type: "remote",
+ url: "http://fake.com/endpoint",
+ enabled: true,
+ updateCycleInMs: 300,
+ },
+ {
+ id: "alocalprovider",
+ type: "local",
+ enabled: true,
+ messages: FAKE_LOCAL_MESSAGES,
+ },
+ ]);
+ fetchStub.withArgs("http://fake.com/endpoint").resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ messages: NEW_MESSAGES }),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+
+ clock.tick(301);
+ await Router.loadMessagesFromAllProviders();
+
+ // These are the new messages
+ assertRouterContainsMessages(NEW_MESSAGES);
+ // These are the local messages that should not have been deleted
+ assertRouterContainsMessages(FAKE_LOCAL_MESSAGES);
+ });
+ it("should parse the triggers in the messages and register the trigger listeners", async () => {
+ sandbox.spy(
+ ASRouterTriggerListeners.get("openURL"),
+ "init"
+ ); /* eslint-disable object-property-newline */
+
+ /* eslint-disable object-curly-newline */ await createRouterAndInit([
+ {
+ id: "foo",
+ type: "local",
+ enabled: true,
+ messages: [
+ {
+ id: "foo",
+ template: "simple_template",
+ trigger: { id: "firstRun" },
+ content: { title: "Foo", body: "Foo123" },
+ },
+ {
+ id: "bar1",
+ template: "simple_template",
+ trigger: {
+ id: "openURL",
+ params: ["www.mozilla.org", "www.mozilla.com"],
+ },
+ content: { title: "Bar1", body: "Bar123" },
+ },
+ {
+ id: "bar2",
+ template: "simple_template",
+ trigger: { id: "openURL", params: ["www.example.com"] },
+ content: { title: "Bar2", body: "Bar123" },
+ },
+ ],
+ },
+ ]); /* eslint-enable object-property-newline */
+ /* eslint-enable object-curly-newline */ assert.calledTwice(
+ ASRouterTriggerListeners.get("openURL").init
+ );
+ assert.calledWithExactly(
+ ASRouterTriggerListeners.get("openURL").init,
+ Router._triggerHandler,
+ ["www.mozilla.org", "www.mozilla.com"],
+ undefined
+ );
+ assert.calledWithExactly(
+ ASRouterTriggerListeners.get("openURL").init,
+ Router._triggerHandler,
+ ["www.example.com"],
+ undefined
+ );
+ });
+ it("should parse the message's messagesLoaded trigger and immediately fire trigger", async () => {
+ setMessageProviderPref([
+ {
+ id: "foo",
+ type: "local",
+ enabled: true,
+ messages: [
+ {
+ id: "bar3",
+ template: "simple_template",
+ trigger: { id: "messagesLoaded" },
+ content: { title: "Bar3", body: "Bar123" },
+ },
+ ],
+ },
+ ]);
+ Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
+ sandbox.spy(Router, "sendTriggerMessage");
+ await initASRouter(Router);
+ assert.calledOnce(Router.sendTriggerMessage);
+ assert.calledWith(
+ Router.sendTriggerMessage,
+ sandbox.match({ id: "messagesLoaded" }),
+ true
+ );
+ });
+ it("should gracefully handle messages loading before a window or browser exists", async () => {
+ sandbox.stub(global, "gBrowser").value(undefined);
+ sandbox
+ .stub(global.Services.wm, "getMostRecentBrowserWindow")
+ .returns(undefined);
+ setMessageProviderPref([
+ {
+ id: "foo",
+ type: "local",
+ enabled: true,
+ messages: [
+ "whatsnew_panel_message",
+ "cfr_doorhanger",
+ "toolbar_badge",
+ "update_action",
+ "infobar",
+ "spotlight",
+ "toast_notification",
+ ].map((template, i) => {
+ return {
+ id: `foo${i}`,
+ template,
+ trigger: { id: "messagesLoaded" },
+ content: { title: `Foo${i}`, body: "Bar123" },
+ };
+ }),
+ },
+ ]);
+ Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS));
+ sandbox.spy(Router, "sendTriggerMessage");
+ await initASRouter(Router);
+ assert.calledWith(
+ Router.sendTriggerMessage,
+ sandbox.match({ id: "messagesLoaded" }),
+ true
+ );
+ });
+ it("should gracefully handle RemoteSettings blowing up and dispatch undesired event", async () => {
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .rejects("fake error");
+ await createRouterAndInit();
+ assert.calledWith(initParams.dispatchCFRAction, {
+ data: {
+ action: "asrouter_undesired_event",
+ event: "ASR_RS_ERROR",
+ event_context: "remotey-settingsy",
+ message_id: "n/a",
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "AS_ROUTER_TELEMETRY_USER_EVENT",
+ });
+ });
+ it("should dispatch undesired event if RemoteSettings returns no messages", async () => {
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .resolves([]);
+ assert.calledWith(initParams.dispatchCFRAction, {
+ data: {
+ action: "asrouter_undesired_event",
+ event: "ASR_RS_NO_MESSAGES",
+ event_context: "remotey-settingsy",
+ message_id: "n/a",
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "AS_ROUTER_TELEMETRY_USER_EVENT",
+ });
+ });
+ it("should download the attachment if RemoteSettings returns some messages", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .resolves([{ id: "message_1" }]);
+ const spy = sandbox.spy();
+ global.Downloader.prototype.downloadToDisk = spy;
+ const provider = {
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ };
+ await createRouterAndInit([provider]);
+
+ assert.calledOnce(spy);
+ });
+ it("should dispatch undesired event if the ms-language-packs returns no messages", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .resolves([{ id: "message_1" }]);
+ sandbox
+ .stub(global.KintoHttpClient.prototype, "getRecord")
+ .resolves(null);
+ const provider = {
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ };
+ await createRouterAndInit([provider]);
+
+ assert.calledWith(initParams.dispatchCFRAction, {
+ data: {
+ action: "asrouter_undesired_event",
+ event: "ASR_RS_NO_MESSAGES",
+ event_context: "ms-language-packs",
+ message_id: "n/a",
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "AS_ROUTER_TELEMETRY_USER_EVENT",
+ });
+ });
+ });
+
+ describe("#_updateMessageProviders", () => {
+ it("should correctly replace %STARTPAGE_VERSION% in remote provider urls", async () => {
+ // If this test fails, you need to update the constant STARTPAGE_VERSION in
+ // ASRouter.jsm to match the `version` property of provider-response-schema.json
+ const expectedStartpageVersion = ProviderResponseSchema.version;
+ const provider = {
+ id: "foo",
+ enabled: true,
+ type: "remote",
+ url: "https://www.mozilla.org/%STARTPAGE_VERSION%/",
+ };
+ setMessageProviderPref([provider]);
+ await Router._updateMessageProviders();
+ assert.equal(
+ Router.state.providers[0].url,
+ `https://www.mozilla.org/${parseInt(expectedStartpageVersion, 10)}/`
+ );
+ });
+ it("should replace other params in remote provider urls by calling Services.urlFormater.formatURL", async () => {
+ const url = "https://www.example.com/";
+ const replacedUrl = "https://www.foo.bar/";
+ const stub = sandbox
+ .stub(global.Services.urlFormatter, "formatURL")
+ .withArgs(url)
+ .returns(replacedUrl);
+ const provider = { id: "foo", enabled: true, type: "remote", url };
+ setMessageProviderPref([provider]);
+ await Router._updateMessageProviders();
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, url);
+ assert.equal(Router.state.providers[0].url, replacedUrl);
+ });
+ it("should only add the providers that are enabled", async () => {
+ const providers = [
+ {
+ id: "foo",
+ enabled: false,
+ type: "remote",
+ url: "https://www.foo.com/",
+ },
+ {
+ id: "bar",
+ enabled: true,
+ type: "remote",
+ url: "https://www.bar.com/",
+ },
+ ];
+ setMessageProviderPref(providers);
+ await Router._updateMessageProviders();
+ assert.equal(Router.state.providers.length, 1);
+ assert.equal(Router.state.providers[0].id, providers[1].id);
+ });
+ });
+
+ describe("#handleMessageRequest", () => {
+ beforeEach(async () => {
+ await Router.setState(() => ({
+ providers: [{ id: "snippets" }, { id: "badge" }],
+ }));
+ });
+ it("should not return a blocked message", async () => {
+ // Block all messages except the first
+ await Router.setState(() => ({
+ messages: [
+ { id: "foo", provider: "snippets", groups: ["snippets"] },
+ { id: "bar", provider: "snippets", groups: ["snippets"] },
+ ],
+ messageBlockList: ["foo"],
+ }));
+ await Router.handleMessageRequest({
+ provider: "snippets",
+ });
+ assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, {
+ messages: [{ id: "bar", provider: "snippets", groups: ["snippets"] }],
+ });
+ });
+ it("should not return a message from a disabled group", async () => {
+ ASRouterTargeting.findMatchingMessage.callsFake(
+ ({ messages }) => messages[0]
+ );
+ // Block all messages except the first
+ await Router.setState(() => ({
+ messages: [
+ { id: "foo", provider: "snippets", groups: ["snippets"] },
+ { id: "bar", provider: "snippets", groups: ["snippets"] },
+ ],
+ groups: [{ id: "snippets", enabled: false }],
+ }));
+ const result = await Router.handleMessageRequest({
+ provider: "snippets",
+ });
+ assert.isNull(result);
+ });
+ it("should not return a message from a blocked campaign", async () => {
+ // Block all messages except the first
+ await Router.setState(() => ({
+ messages: [
+ {
+ id: "foo",
+ provider: "snippets",
+ campaign: "foocampaign",
+ groups: ["snippets"],
+ },
+ { id: "bar", provider: "snippets", groups: ["snippets"] },
+ ],
+ messageBlockList: ["foocampaign"],
+ }));
+
+ await Router.handleMessageRequest({
+ provider: "snippets",
+ });
+ assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, {
+ messages: [{ id: "bar", provider: "snippets", groups: ["snippets"] }],
+ });
+ });
+ it("should not return a message excluded by the provider", async () => {
+ // There are only two providers; block the FAKE_LOCAL_PROVIDER, leaving
+ // only FAKE_REMOTE_PROVIDER unblocked, which provides only one message
+ await Router.setState(() => ({
+ providers: [{ id: "snippets", exclude: ["foo"] }],
+ }));
+
+ await Router.setState(() => ({
+ messages: [{ id: "foo", provider: "snippets" }],
+ messageBlockList: ["foocampaign"],
+ }));
+
+ const result = await Router.handleMessageRequest({
+ provider: "snippets",
+ });
+ assert.isNull(result);
+ });
+ it("should not return a message if the frequency cap has been hit", async () => {
+ sandbox.stub(Router, "isBelowFrequencyCaps").returns(false);
+ await Router.setState(() => ({
+ messages: [{ id: "foo", provider: "snippets" }],
+ }));
+ const result = await Router.handleMessageRequest({
+ provider: "snippets",
+ });
+ assert.isNull(result);
+ });
+ it("should get unblocked messages that match the trigger", async () => {
+ const message1 = {
+ id: "1",
+ campaign: "foocampaign",
+ trigger: { id: "foo" },
+ groups: ["snippets"],
+ provider: "snippets",
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ trigger: { id: "bar" },
+ groups: ["snippets"],
+ provider: "snippets",
+ };
+ await Router.setState({ messages: [message2, message1] });
+ // Just return the first message provided as arg
+ ASRouterTargeting.findMatchingMessage.callsFake(
+ ({ messages }) => messages[0]
+ );
+
+ const result = Router.handleMessageRequest({ triggerId: "foo" });
+
+ assert.deepEqual(result, message1);
+ });
+ it("should get unblocked messages that match trigger and template", async () => {
+ const message1 = {
+ id: "1",
+ campaign: "foocampaign",
+ template: "badge",
+ trigger: { id: "foo" },
+ groups: ["badge"],
+ provider: "badge",
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ template: "snippet",
+ trigger: { id: "foo" },
+ groups: ["snippets"],
+ provider: "snippets",
+ };
+ await Router.setState({ messages: [message2, message1] });
+ // Just return the first message provided as arg
+ ASRouterTargeting.findMatchingMessage.callsFake(
+ ({ messages }) => messages[0]
+ );
+
+ const result = Router.handleMessageRequest({
+ triggerId: "foo",
+ template: "badge",
+ });
+
+ assert.deepEqual(result, message1);
+ });
+ it("should have messageImpressions in the message context", () => {
+ assert.propertyVal(
+ Router._getMessagesContext(),
+ "messageImpressions",
+ Router.state.messageImpressions
+ );
+ });
+ it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => {
+ const message1 = {
+ provider: "whats_new",
+ id: "1",
+ template: "whatsnew_panel_message",
+ trigger: { id: "whatsNewPanelOpened" },
+ groups: ["whats_new"],
+ };
+ const message2 = {
+ provider: "whats_new",
+ id: "2",
+ template: "whatsnew_panel_message",
+ trigger: { id: "whatsNewPanelOpened" },
+ groups: ["whats_new"],
+ };
+ const message3 = {
+ provider: "whats_new",
+ id: "3",
+ template: "badge",
+ groups: ["whats_new"],
+ };
+ ASRouterTargeting.findMatchingMessage.callsFake(() => [
+ message2,
+ message1,
+ ]);
+ await Router.setState({
+ messages: [message3, message2, message1],
+ providers: [{ id: "whats_new" }],
+ });
+ const result = await Router.handleMessageRequest({
+ template: "whatsnew_panel_message",
+ triggerId: "whatsNewPanelOpened",
+ returnAll: true,
+ });
+
+ assert.deepEqual(result, [message2, message1]);
+ });
+ it("should forward trigger param info", async () => {
+ const trigger = {
+ triggerId: "foo",
+ triggerParam: "bar",
+ triggerContext: "context",
+ };
+ const message1 = {
+ id: "1",
+ campaign: "foocampaign",
+ trigger: { id: "foo" },
+ groups: ["snippets"],
+ provider: "snippets",
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ trigger: { id: "bar" },
+ groups: ["badge"],
+ provider: "badge",
+ };
+ await Router.setState({ messages: [message2, message1] });
+ // Just return the first message provided as arg
+
+ Router.handleMessageRequest(trigger);
+
+ assert.calledOnce(ASRouterTargeting.findMatchingMessage);
+
+ const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args;
+ assert.propertyVal(options.trigger, "id", trigger.triggerId);
+ assert.propertyVal(options.trigger, "param", trigger.triggerParam);
+ assert.propertyVal(options.trigger, "context", trigger.triggerContext);
+ });
+ it("should cache snippets messages", async () => {
+ const trigger = {
+ triggerId: "foo",
+ triggerParam: "bar",
+ triggerContext: "context",
+ };
+ const message1 = {
+ id: "1",
+ provider: "snippets",
+ campaign: "foocampaign",
+ trigger: { id: "foo" },
+ groups: ["snippets"],
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ trigger: { id: "bar" },
+ groups: ["snippets"],
+ };
+ await Router.setState({ messages: [message2, message1] });
+
+ Router.handleMessageRequest(trigger);
+
+ assert.calledOnce(ASRouterTargeting.findMatchingMessage);
+
+ const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args;
+ assert.propertyVal(options, "shouldCache", true);
+ });
+ it("should not cache badge messages", async () => {
+ const trigger = {
+ triggerId: "bar",
+ triggerParam: "bar",
+ triggerContext: "context",
+ };
+ const message1 = {
+ id: "1",
+ provider: "snippets",
+ campaign: "foocampaign",
+ trigger: { id: "foo" },
+ groups: ["snippets"],
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ trigger: { id: "bar" },
+ groups: ["badge"],
+ provider: "badge",
+ };
+ await Router.setState({ messages: [message2, message1] });
+ // Just return the first message provided as arg
+
+ Router.handleMessageRequest(trigger);
+
+ assert.calledOnce(ASRouterTargeting.findMatchingMessage);
+
+ const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args;
+ assert.propertyVal(options, "shouldCache", false);
+ });
+ it("should filter out messages without a trigger (or different) when a triggerId is defined", async () => {
+ const trigger = { triggerId: "foo" };
+ const message1 = {
+ id: "1",
+ campaign: "foocampaign",
+ trigger: { id: "foo" },
+ groups: ["snippets"],
+ provider: "snippets",
+ };
+ const message2 = {
+ id: "2",
+ campaign: "foocampaign",
+ trigger: { id: "bar" },
+ groups: ["snippets"],
+ provider: "snippets",
+ };
+ const message3 = {
+ id: "3",
+ campaign: "bazcampaign",
+ groups: ["snippets"],
+ provider: "snippets",
+ };
+ await Router.setState({
+ messages: [message2, message1, message3],
+ groups: [{ id: "snippets", enabled: true }],
+ });
+ // Just return the first message provided as arg
+ ASRouterTargeting.findMatchingMessage.callsFake(args => args.messages);
+
+ const result = Router.handleMessageRequest(trigger);
+
+ assert.lengthOf(result, 1);
+ assert.deepEqual(result[0], message1);
+ });
+ });
+
+ describe("#uninit", () => {
+ it("should unregister the trigger listeners", () => {
+ for (const listener of ASRouterTriggerListeners.values()) {
+ sandbox.spy(listener, "uninit");
+ }
+
+ Router.uninit();
+
+ for (const listener of ASRouterTriggerListeners.values()) {
+ assert.calledOnce(listener.uninit);
+ }
+ });
+ it("should set .dispatchCFRAction to null", () => {
+ Router.uninit();
+ assert.isNull(Router.dispatchCFRAction);
+ assert.isNull(Router.clearChildMessages);
+ assert.isNull(Router.sendTelemetry);
+ });
+ it("should save previousSessionEnd", () => {
+ Router.uninit();
+
+ assert.calledOnce(Router._storage.set);
+ assert.calledWithExactly(
+ Router._storage.set,
+ "previousSessionEnd",
+ sinon.match.number
+ );
+ });
+ it("should remove the observer for `intl:app-locales-changed`", () => {
+ sandbox.spy(global.Services.obs, "removeObserver");
+ Router.uninit();
+
+ assert.calledWithExactly(
+ global.Services.obs.removeObserver,
+ Router._onLocaleChanged,
+ "intl:app-locales-changed"
+ );
+ });
+ it("should remove the pref observer for `USE_REMOTE_L10N_PREF`", async () => {
+ sandbox.spy(global.Services.prefs, "removeObserver");
+ Router.uninit();
+
+ // Grab the last call as #uninit() also involves multiple calls of `Services.prefs.removeObserver`.
+ const call = global.Services.prefs.removeObserver.lastCall;
+ assert.calledWithExactly(call, USE_REMOTE_L10N_PREF, Router);
+ });
+ });
+
+ describe("sendNewTabMessage", () => {
+ it("should construct an appropriate response message", async () => {
+ Router.loadMessagesFromAllProviders.resetHistory();
+ Router.loadMessagesFromAllProviders.onFirstCall().resolves();
+
+ let message = {
+ id: "foo",
+ provider: "snippets",
+ groups: ["snippets"],
+ };
+
+ await Router.setState({
+ messages: [message],
+ providers: [{ id: "snippets" }],
+ });
+
+ ASRouterTargeting.findMatchingMessage.callsFake(
+ ({ messages }) => messages[0]
+ );
+
+ let response = await Router.sendNewTabMessage({
+ tabId: 0,
+ browser: {},
+ });
+
+ assert.deepEqual(response.message, message);
+ });
+ it("should send an empty object message if no messages are available", async () => {
+ await Router.setState({ messages: [] });
+ let response = await Router.sendNewTabMessage({
+ tabId: 0,
+ browser: {},
+ });
+
+ assert.deepEqual(response.message, {});
+ });
+
+ describe("#addPreviewEndpoint", () => {
+ it("should make a request to the provided endpoint", async () => {
+ const url = "https://snippets-admin.mozilla.org/foo";
+ const browser = {};
+ browser.sendMessageToActor = sandbox.stub();
+
+ await Router.sendNewTabMessage({
+ endpoint: { url },
+ tabId: 0,
+ browser,
+ });
+
+ assert.calledWith(global.fetch, url);
+ assert.lengthOf(
+ Router.state.providers.filter(p => p.url === url),
+ 0
+ );
+ });
+ it("should send EnterSnippetPreviewMode when adding a preview endpoint", async () => {
+ const url = "https://snippets-admin.mozilla.org/foo";
+ const browser = {};
+ browser.sendMessageToActor = sandbox.stub();
+
+ await Router.addPreviewEndpoint(url, browser);
+
+ assert.calledWithExactly(
+ browser.sendMessageToActor,
+ "EnterSnippetsPreviewMode",
+ {},
+ "ASRouter"
+ );
+ });
+ it("should not add a url that is not from an allowed host", async () => {
+ const url = "https://mozilla.org";
+ const browser = {};
+ browser.sendMessageToActor = sandbox.stub();
+
+ await Router.addPreviewEndpoint(url, browser);
+
+ assert.lengthOf(
+ Router.state.providers.filter(p => p.url === url),
+ 0
+ );
+ });
+ it("should reject bad urls", async () => {
+ const url = "foo";
+ const browser = {};
+ browser.sendMessageToActor = sandbox.stub();
+
+ await Router.addPreviewEndpoint(url, browser);
+
+ assert.lengthOf(
+ Router.state.providers.filter(p => p.url === url),
+ 0
+ );
+ });
+ });
+
+ it("should record telemetry for message request duration", async () => {
+ const startTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "start"
+ );
+ const finishTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "finish"
+ );
+ sandbox.stub(Router, "handleMessageRequest");
+ const tabId = 123;
+ await Router.sendNewTabMessage({
+ tabId,
+ browser: {},
+ });
+
+ // Called once for the messagesLoaded trigger and once for the above call.
+ assert.calledTwice(startTelemetryStopwatch);
+ assert.calledWithExactly(
+ startTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { tabId }
+ );
+ assert.calledTwice(finishTelemetryStopwatch);
+ assert.calledWithExactly(
+ finishTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { tabId }
+ );
+ });
+ it("should return the preview message if that's available and remove it from Router.state", async () => {
+ const expectedObj = {
+ id: "foo",
+ groups: ["preview"],
+ provider: "preview",
+ };
+ await Router.setState({
+ messages: [expectedObj],
+ providers: [{ id: "preview" }],
+ });
+
+ ASRouterTargeting.findMatchingMessage.callsFake(
+ ({ messages }) => expectedObj
+ );
+
+ Router.loadMessagesFromAllProviders.resetHistory();
+ Router.loadMessagesFromAllProviders.onFirstCall().resolves();
+
+ let response = await Router.sendNewTabMessage({
+ endpoint: { url: "foo.com" },
+ tabId: 0,
+ browser: {},
+ });
+
+ assert.deepEqual(response.message, expectedObj);
+
+ assert.isUndefined(
+ Router.state.messages.find(m => m.provider === "preview")
+ );
+ });
+ });
+
+ describe("#setMessageById", async () => {
+ it("should send an empty message if provided id did not resolve to a message", async () => {
+ let response = await Router.setMessageById({ id: -1 }, true, {});
+ assert.deepEqual(response.message, {});
+ });
+ });
+
+ describe("#isUnblockedMessage", () => {
+ it("should block a message if the group is blocked", async () => {
+ const msg = { id: "msg1", groups: ["foo"], provider: "unit-test" };
+ await Router.setState({
+ groups: [{ id: "foo", enabled: false }],
+ messages: [msg],
+ providers: [{ id: "unit-test" }],
+ });
+ assert.isFalse(Router.isUnblockedMessage(msg));
+
+ await Router.setState({ groups: [{ id: "foo", enabled: true }] });
+
+ assert.isTrue(Router.isUnblockedMessage(msg));
+ });
+ it("should block a message if at least one group is blocked", async () => {
+ const msg = {
+ id: "msg1",
+ groups: ["foo", "bar"],
+ provider: "unit-test",
+ };
+ await Router.setState({
+ groups: [
+ { id: "foo", enabled: false },
+ { id: "bar", enabled: false },
+ ],
+ messages: [msg],
+ providers: [{ id: "unit-test" }],
+ });
+ assert.isFalse(Router.isUnblockedMessage(msg));
+
+ await Router.setState({
+ groups: [
+ { id: "foo", enabled: true },
+ { id: "bar", enabled: false },
+ ],
+ });
+
+ assert.isFalse(Router.isUnblockedMessage(msg));
+ });
+ });
+
+ describe("#blockMessageById", () => {
+ it("should add the id to the messageBlockList", async () => {
+ await Router.blockMessageById("foo");
+ assert.isTrue(Router.state.messageBlockList.includes("foo"));
+ });
+ it("should add the campaign to the messageBlockList instead of id if .campaign is specified and not select messages of that campaign again", async () => {
+ await Router.setState({
+ messages: [
+ { id: "1", campaign: "foocampaign" },
+ { id: "2", campaign: "foocampaign" },
+ ],
+ });
+ await Router.blockMessageById("1");
+
+ assert.isTrue(Router.state.messageBlockList.includes("foocampaign"));
+ assert.isEmpty(Router.state.messages.filter(Router.isUnblockedMessage));
+ });
+ it("should be able to add multiple items to the messageBlockList", async () => {
+ await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id));
+ assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[0].id));
+ assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[1].id));
+ });
+ it("should save the messageBlockList", async () => {
+ await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id));
+ assert.calledWithExactly(Router._storage.set, "messageBlockList", [
+ FAKE_BUNDLE[0].id,
+ FAKE_BUNDLE[1].id,
+ ]);
+ });
+ });
+
+ describe("#unblockMessageById", () => {
+ it("should remove the id from the messageBlockList", async () => {
+ await Router.blockMessageById("foo");
+ assert.isTrue(Router.state.messageBlockList.includes("foo"));
+ await Router.unblockMessageById("foo");
+ assert.isFalse(Router.state.messageBlockList.includes("foo"));
+ });
+ it("should remove the campaign from the messageBlockList if it is defined", async () => {
+ await Router.setState({ messages: [{ id: "1", campaign: "foo" }] });
+ await Router.blockMessageById("1");
+ assert.isTrue(
+ Router.state.messageBlockList.includes("foo"),
+ "blocklist has campaign id"
+ );
+ await Router.unblockMessageById("1");
+ assert.isFalse(
+ Router.state.messageBlockList.includes("foo"),
+ "campaign id removed from blocklist"
+ );
+ });
+ it("should save the messageBlockList", async () => {
+ await Router.unblockMessageById("foo");
+ assert.calledWithExactly(Router._storage.set, "messageBlockList", []);
+ });
+ });
+
+ describe("#routeCFRMessage", () => {
+ it("should allow for echoing back message modifications", () => {
+ const message = { somekey: "some value" };
+ const data = { content: message };
+ const browser = {};
+ let msg = Router.routeCFRMessage(data.content, browser, data, false);
+ assert.deepEqual(msg.message, message);
+ });
+ it("should call CFRPageActions.forceRecommendation if the template is cfr_action and force is true", async () => {
+ sandbox.stub(CFRPageActions, "forceRecommendation");
+ const testMessage = { id: "foo", template: "cfr_doorhanger" };
+ await Router.setState({ messages: [testMessage] });
+ Router.routeCFRMessage(testMessage, {}, null, true);
+
+ assert.calledOnce(CFRPageActions.forceRecommendation);
+ });
+ it("should call CFRPageActions.addRecommendation if the template is cfr_action and force is false", async () => {
+ sandbox.stub(CFRPageActions, "addRecommendation");
+ const testMessage = { id: "foo", template: "cfr_doorhanger" };
+ await Router.setState({ messages: [testMessage] });
+ Router.routeCFRMessage(testMessage, {}, {}, false);
+ assert.calledOnce(CFRPageActions.addRecommendation);
+ });
+ });
+
+ describe("#updateTargetingParameters", () => {
+ it("should return an object containing the whole state", async () => {
+ sandbox.stub(Router, "getTargetingParameters").resolves({});
+ let msg = await Router.updateTargetingParameters();
+ let expected = Object.assign({}, Router.state, {
+ providerPrefs: ASRouterPreferences.providers,
+ userPrefs: ASRouterPreferences.getAllUserPreferences(),
+ targetingParameters: {},
+ errors: Router.errors,
+ });
+
+ assert.deepEqual(msg, expected);
+ });
+ });
+
+ describe("#reachEvent", () => {
+ let experimentAPIStub;
+ let featureIds = ["cfr", "moments-page", "infobar", "spotlight"];
+ beforeEach(() => {
+ let getExperimentMetaDataStub = sandbox.stub();
+ let getAllBranchesStub = sandbox.stub();
+ featureIds.forEach(feature => {
+ global.NimbusFeatures[feature].getAllVariables.returns({
+ id: `message-${feature}`,
+ });
+ getExperimentMetaDataStub.withArgs({ featureId: feature }).returns({
+ slug: `slug-${feature}`,
+ branch: {
+ slug: `branch-${feature}`,
+ },
+ });
+ getAllBranchesStub.withArgs(`slug-${feature}`).resolves([
+ {
+ slug: `other-branch-${feature}`,
+ [feature]: { value: { trigger: "unit-test" } },
+ },
+ ]);
+ });
+ experimentAPIStub = {
+ getExperimentMetaData: getExperimentMetaDataStub,
+ getAllBranches: getAllBranchesStub,
+ };
+ globals.set("ExperimentAPI", experimentAPIStub);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should tag `forReachEvent` for all the expected message types", async () => {
+ // This should match the `providers.messaging-experiments`
+ let response = await MessageLoaderUtils.loadMessagesForProvider({
+ type: "remote-experiments",
+ featureIds,
+ });
+
+ // 1 message for reach 1 for expose
+ assert.property(response, "messages");
+ assert.lengthOf(response.messages, featureIds.length * 2);
+ assert.lengthOf(
+ response.messages.filter(m => m.forReachEvent),
+ featureIds.length
+ );
+ });
+ });
+
+ describe("#sendTriggerMessage", () => {
+ it("should pass the trigger to ASRouterTargeting when sending trigger message", async () => {
+ await Router.setState({
+ messages: [
+ {
+ id: "foo1",
+ provider: "onboarding",
+ template: "onboarding",
+ trigger: { id: "firstRun" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ groups: ["onboarding"],
+ },
+ ],
+ providers: [{ id: "onboarding" }],
+ });
+
+ Router.loadMessagesFromAllProviders.resetHistory();
+ Router.loadMessagesFromAllProviders.onFirstCall().resolves();
+
+ await Router.sendTriggerMessage({
+ tabId: 0,
+ browser: {},
+ id: "firstRun",
+ });
+
+ assert.calledOnce(ASRouterTargeting.findMatchingMessage);
+ assert.deepEqual(
+ ASRouterTargeting.findMatchingMessage.firstCall.args[0].trigger,
+ {
+ id: "firstRun",
+ param: undefined,
+ context: undefined,
+ }
+ );
+ });
+ it("should record telemetry information", async () => {
+ const startTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "start"
+ );
+ const finishTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "finish"
+ );
+
+ const tabId = 123;
+
+ await Router.sendTriggerMessage({
+ tabId,
+ browser: {},
+ id: "firstRun",
+ });
+
+ assert.calledTwice(startTelemetryStopwatch);
+ assert.calledWithExactly(
+ startTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { tabId }
+ );
+ assert.calledTwice(finishTelemetryStopwatch);
+ assert.calledWithExactly(
+ finishTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { tabId }
+ );
+ });
+ it("should have previousSessionEnd in the message context", () => {
+ assert.propertyVal(
+ Router._getMessagesContext(),
+ "previousSessionEnd",
+ 100
+ );
+ });
+ it("should record the Reach event if found any", async () => {
+ let messages = [
+ {
+ id: "foo1",
+ forReachEvent: { sent: false, group: "cfr" },
+ experimentSlug: "exp01",
+ branchSlug: "branch01",
+ template: "simple_template",
+ trigger: { id: "foo" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ {
+ id: "foo2",
+ template: "simple_template",
+ trigger: { id: "bar" },
+ content: { title: "Foo2", body: "Foo123-2" },
+ provider: "onboarding",
+ },
+ {
+ id: "foo3",
+ forReachEvent: { sent: false, group: "cfr" },
+ experimentSlug: "exp02",
+ branchSlug: "branch02",
+ template: "simple_template",
+ trigger: { id: "foo" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ ];
+ sandbox.stub(Router, "handleMessageRequest").resolves(messages);
+ sandbox.spy(Services.telemetry, "recordEvent");
+
+ await Router.sendTriggerMessage({
+ tabId: 0,
+ browser: {},
+ id: "foo",
+ });
+
+ assert.calledTwice(Services.telemetry.recordEvent);
+ });
+ it("should not record the Reach event if it's already sent", async () => {
+ let messages = [
+ {
+ id: "foo1",
+ forReachEvent: { sent: true, group: "cfr" },
+ experimentSlug: "exp01",
+ branchSlug: "branch01",
+ template: "simple_template",
+ trigger: { id: "foo" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ ];
+ sandbox.stub(Router, "handleMessageRequest").resolves(messages);
+ sandbox.spy(Services.telemetry, "recordEvent");
+
+ await Router.sendTriggerMessage({
+ tabId: 0,
+ browser: {},
+ id: "foo",
+ });
+ assert.notCalled(Services.telemetry.recordEvent);
+ });
+ it("should record the Exposure event for each valid feature", async () => {
+ ["cfr_doorhanger", "update_action", "infobar", "spotlight"].forEach(
+ async template => {
+ let featureMap = {
+ cfr_doorhanger: "cfr",
+ spotlight: "spotlight",
+ infobar: "infobar",
+ update_action: "moments-page",
+ };
+ assert.notCalled(
+ global.NimbusFeatures[featureMap[template]].recordExposureEvent
+ );
+
+ let messages = [
+ {
+ id: "foo1",
+ template,
+ trigger: { id: "foo" },
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ ];
+ sandbox.stub(Router, "handleMessageRequest").resolves(messages);
+
+ await Router.sendTriggerMessage({
+ tabId: 0,
+ browser: {},
+ id: "foo",
+ });
+
+ assert.calledOnce(
+ global.NimbusFeatures[featureMap[template]].recordExposureEvent
+ );
+ }
+ );
+ });
+ });
+
+ describe("forceAttribution", () => {
+ let setReferrerUrl;
+ beforeEach(() => {
+ setReferrerUrl = sinon.spy();
+ global.Cc["@mozilla.org/mac-attribution;1"] = {
+ getService: () => ({ setReferrerUrl }),
+ };
+
+ sandbox.stub(global.Services.env, "set");
+ });
+ it("should double encode on windows", async () => {
+ sandbox.stub(fakeAttributionCode, "writeAttributionFile");
+
+ Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" });
+
+ assert.notCalled(setReferrerUrl);
+ assert.calledWithMatch(
+ fakeAttributionCode.writeAttributionFile,
+ "foo%3DFOO!%26bar%3DBAR%253F"
+ );
+ });
+ it("should set referrer on mac", async () => {
+ sandbox.stub(global.AppConstants, "platform").value("macosx");
+
+ Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" });
+
+ assert.calledOnce(setReferrerUrl);
+ assert.calledWithMatch(setReferrerUrl, "", "?foo=FOO!&bar=BAR%3F");
+ });
+ });
+
+ describe("#forceWNPanel", () => {
+ let browser = {
+ ownerGlobal: {
+ document: new Document(),
+ PanelUI: {
+ showSubView: sinon.stub(),
+ panel: {
+ setAttribute: sinon.stub(),
+ },
+ },
+ },
+ };
+ let fakePanel = {
+ setAttribute: sinon.stub(),
+ };
+ sinon
+ .stub(browser.ownerGlobal.document, "getElementById")
+ .returns(fakePanel);
+
+ it("should call enableToolbarButton", async () => {
+ await Router.forceWNPanel(browser);
+ assert.calledOnce(FakeToolbarPanelHub.enableToolbarButton);
+ assert.calledOnce(browser.ownerGlobal.PanelUI.showSubView);
+ assert.calledWith(fakePanel.setAttribute, "noautohide", true);
+ });
+ });
+
+ describe("_triggerHandler", () => {
+ it("should call #sendTriggerMessage with the correct trigger", () => {
+ const getter = sandbox.stub();
+ getter.returns(false);
+ sandbox.stub(global.BrowserHandler, "kiosk").get(getter);
+ sinon.spy(Router, "sendTriggerMessage");
+ const browser = {};
+ const trigger = { id: "FAKE_TRIGGER", param: "some fake param" };
+ Router._triggerHandler(browser, trigger);
+ assert.calledOnce(Router.sendTriggerMessage);
+ assert.calledWith(
+ Router.sendTriggerMessage,
+ sandbox.match({
+ id: "FAKE_TRIGGER",
+ param: "some fake param",
+ })
+ );
+ });
+ });
+
+ describe("_triggerHandler_kiosk", () => {
+ it("should not call #sendTriggerMessage", () => {
+ const getter = sandbox.stub();
+ getter.returns(true);
+ sandbox.stub(global.BrowserHandler, "kiosk").get(getter);
+ sinon.spy(Router, "sendTriggerMessage");
+ const browser = {};
+ const trigger = { id: "FAKE_TRIGGER", param: "some fake param" };
+ Router._triggerHandler(browser, trigger);
+ assert.notCalled(Router.sendTriggerMessage);
+ });
+ });
+
+ describe("valid preview endpoint", () => {
+ it("should report an error if url protocol is not https", () => {
+ sandbox.stub(console, "error");
+
+ assert.equal(false, Router._validPreviewEndpoint("http://foo.com"));
+ assert.calledTwice(console.error);
+ });
+ });
+
+ describe("impressions", () => {
+ describe("#addImpression for groups", () => {
+ it("should save an impression in each group-with-frequency in a message", async () => {
+ const fooMessageImpressions = [0];
+ const aGroupImpressions = [0, 1, 2];
+ const bGroupImpressions = [3, 4, 5];
+ const cGroupImpressions = [6, 7, 8];
+
+ const message = {
+ id: "foo",
+ provider: "bar",
+ groups: ["a", "b", "c"],
+ };
+ const groups = [
+ { id: "a", frequency: { lifetime: 3 } },
+ { id: "b", frequency: { lifetime: 4 } },
+ { id: "c", frequency: { lifetime: 5 } },
+ ];
+ await Router.setState(state => {
+ // Add provider
+ const providers = [...state.providers];
+ // Add fooMessageImpressions
+ // eslint-disable-next-line no-shadow
+ const messageImpressions = Object.assign(
+ {},
+ state.messageImpressions
+ );
+ let gImpressions = {};
+ gImpressions.a = aGroupImpressions;
+ gImpressions.b = bGroupImpressions;
+ gImpressions.c = cGroupImpressions;
+ messageImpressions.foo = fooMessageImpressions;
+ return {
+ providers,
+ messageImpressions,
+ groups,
+ groupImpressions: gImpressions,
+ };
+ });
+
+ await Router.addImpression(message);
+
+ assert.deepEqual(
+ Router.state.groupImpressions.a,
+ [0, 1, 2, 0],
+ "a impressions"
+ );
+ assert.deepEqual(
+ Router.state.groupImpressions.b,
+ [3, 4, 5, 0],
+ "b impressions"
+ );
+ assert.deepEqual(
+ Router.state.groupImpressions.c,
+ [6, 7, 8, 0],
+ "c impressions"
+ );
+ });
+ });
+
+ describe("#isBelowFrequencyCaps", () => {
+ it("should call #_isBelowItemFrequencyCap for the message and for the provider with the correct impressions and arguments", async () => {
+ sinon.spy(Router, "_isBelowItemFrequencyCap");
+
+ const MAX_MESSAGE_LIFETIME_CAP = 100; // Defined in ASRouter
+ const fooMessageImpressions = [0, 1];
+ const barGroupImpressions = [0, 1, 2];
+
+ const message = {
+ id: "foo",
+ provider: "bar",
+ groups: ["bar"],
+ frequency: { lifetime: 3 },
+ };
+ const groups = [{ id: "bar", frequency: { lifetime: 5 } }];
+
+ await Router.setState(state => {
+ // Add provider
+ const providers = [...state.providers];
+ // Add fooMessageImpressions
+ // eslint-disable-next-line no-shadow
+ const messageImpressions = Object.assign(
+ {},
+ state.messageImpressions
+ );
+ let gImpressions = {};
+ gImpressions.bar = barGroupImpressions;
+ messageImpressions.foo = fooMessageImpressions;
+ return {
+ providers,
+ messageImpressions,
+ groups,
+ groupImpressions: gImpressions,
+ };
+ });
+
+ await Router.isBelowFrequencyCaps(message);
+
+ assert.calledTwice(Router._isBelowItemFrequencyCap);
+ assert.calledWithExactly(
+ Router._isBelowItemFrequencyCap,
+ message,
+ fooMessageImpressions,
+ MAX_MESSAGE_LIFETIME_CAP
+ );
+ assert.calledWithExactly(
+ Router._isBelowItemFrequencyCap,
+ groups[0],
+ barGroupImpressions
+ );
+ });
+ });
+
+ describe("#_isBelowItemFrequencyCap", () => {
+ it("should return false if the # of impressions exceeds the maxLifetimeCap", () => {
+ const item = { id: "foo", frequency: { lifetime: 5 } };
+ const impressions = [0, 1];
+ const maxLifetimeCap = 1;
+ const result = Router._isBelowItemFrequencyCap(
+ item,
+ impressions,
+ maxLifetimeCap
+ );
+ assert.isFalse(result);
+ });
+
+ describe("lifetime frequency caps", () => {
+ it("should return true if .frequency is not defined on the item", () => {
+ const item = { id: "foo" };
+ const impressions = [0, 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return true if there are no impressions", () => {
+ const item = {
+ id: "foo",
+ frequency: {
+ lifetime: 10,
+ custom: [{ period: ONE_DAY_IN_MS, cap: 2 }],
+ },
+ };
+ const impressions = [];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return true if the # of impressions is less than .frequency.lifetime of the item", () => {
+ const item = { id: "foo", frequency: { lifetime: 3 } };
+ const impressions = [0, 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return false if the # of impressions is equal to .frequency.lifetime of the item", async () => {
+ const item = { id: "foo", frequency: { lifetime: 3 } };
+ const impressions = [0, 1, 2];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ it("should return false if the # of impressions is greater than .frequency.lifetime of the item", async () => {
+ const item = { id: "foo", frequency: { lifetime: 3 } };
+ const impressions = [0, 1, 2, 3];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ });
+
+ describe("custom frequency caps", () => {
+ it("should return true if impressions in the time period < the cap and total impressions < the lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 10);
+ const item = {
+ id: "foo",
+ frequency: {
+ custom: [{ period: ONE_DAY_IN_MS, cap: 2 }],
+ lifetime: 3,
+ },
+ };
+ const impressions = [0, ONE_DAY_IN_MS + 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return false if impressions in the time period > the cap and total impressions < the lifetime cap", () => {
+ clock.tick(200);
+ const item = {
+ id: "msg1",
+ frequency: { custom: [{ period: 100, cap: 2 }], lifetime: 3 },
+ };
+ const impressions = [0, 160, 161];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ it("should return false if impressions in one of the time periods > the cap and total impressions < the lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 200);
+ const itemTrue = {
+ id: "msg2",
+ frequency: { custom: [{ period: 100, cap: 2 }] },
+ };
+ const itemFalse = {
+ id: "msg1",
+ frequency: {
+ custom: [
+ { period: 100, cap: 2 },
+ { period: ONE_DAY_IN_MS, cap: 3 },
+ ],
+ },
+ };
+ const impressions = [
+ 0,
+ ONE_DAY_IN_MS + 160,
+ ONE_DAY_IN_MS - 100,
+ ONE_DAY_IN_MS - 200,
+ ];
+ assert.isTrue(Router._isBelowItemFrequencyCap(itemTrue, impressions));
+ assert.isFalse(
+ Router._isBelowItemFrequencyCap(itemFalse, impressions)
+ );
+ });
+ it("should return false if impressions in the time period < the cap and total impressions > the lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 10);
+ const item = {
+ id: "msg1",
+ frequency: {
+ custom: [{ period: ONE_DAY_IN_MS, cap: 2 }],
+ lifetime: 3,
+ },
+ };
+ const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ it("should return true if daily impressions < the daily cap and there is no lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 10);
+ const item = {
+ id: "msg1",
+ frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] },
+ };
+ const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isTrue(result);
+ });
+ it("should return false if daily impressions > the daily cap and there is no lifetime cap", () => {
+ clock.tick(ONE_DAY_IN_MS + 10);
+ const item = {
+ id: "msg1",
+ frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] },
+ };
+ const impressions = [
+ 0,
+ 1,
+ 2,
+ 3,
+ ONE_DAY_IN_MS + 1,
+ ONE_DAY_IN_MS + 2,
+ ONE_DAY_IN_MS + 3,
+ ];
+ const result = Router._isBelowItemFrequencyCap(item, impressions);
+ assert.isFalse(result);
+ });
+ });
+ });
+
+ describe("#getLongestPeriod", () => {
+ it("should return the period if there is only one definition", () => {
+ const message = {
+ id: "foo",
+ frequency: { custom: [{ period: 200, cap: 2 }] },
+ };
+ assert.equal(Router.getLongestPeriod(message), 200);
+ });
+ it("should return the longest period if there are more than one definitions", () => {
+ const message = {
+ id: "foo",
+ frequency: {
+ custom: [
+ { period: 1000, cap: 3 },
+ { period: ONE_DAY_IN_MS, cap: 5 },
+ { period: 100, cap: 2 },
+ ],
+ },
+ };
+ assert.equal(Router.getLongestPeriod(message), ONE_DAY_IN_MS);
+ });
+ it("should return null if there are is no .frequency", () => {
+ const message = { id: "foo" };
+ assert.isNull(Router.getLongestPeriod(message));
+ });
+ it("should return null if there are is no .frequency.custom", () => {
+ const message = { id: "foo", frequency: { lifetime: 10 } };
+ assert.isNull(Router.getLongestPeriod(message));
+ });
+ });
+
+ describe("cleanup on init", () => {
+ it("should clear messageImpressions for messages which do not exist in state.messages", async () => {
+ const messages = [{ id: "foo", frequency: { lifetime: 10 } }];
+ messageImpressions = { foo: [0], bar: [0, 1] };
+ // Impressions for "bar" should be removed since that id does not exist in messages
+ const result = { foo: [0] };
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.calledWith(Router._storage.set, "messageImpressions", result);
+ assert.deepEqual(Router.state.messageImpressions, result);
+ });
+ it("should clear messageImpressions older than the period if no lifetime impression cap is included", async () => {
+ const CURRENT_TIME = ONE_DAY_IN_MS * 2;
+ clock.tick(CURRENT_TIME);
+ const messages = [
+ {
+ id: "foo",
+ frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 5 }] },
+ },
+ ];
+ messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] };
+ // Only 0 and 1 are more than 24 hours before CURRENT_TIME
+ const result = { foo: [CURRENT_TIME - 10] };
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.calledWith(Router._storage.set, "messageImpressions", result);
+ assert.deepEqual(Router.state.messageImpressions, result);
+ });
+ it("should clear messageImpressions older than the longest period if no lifetime impression cap is included", async () => {
+ const CURRENT_TIME = ONE_DAY_IN_MS * 2;
+ clock.tick(CURRENT_TIME);
+ const messages = [
+ {
+ id: "foo",
+ frequency: {
+ custom: [
+ { period: ONE_DAY_IN_MS, cap: 5 },
+ { period: 100, cap: 2 },
+ ],
+ },
+ },
+ ];
+ messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] };
+ // Only 0 and 1 are more than 24 hours before CURRENT_TIME
+ const result = { foo: [CURRENT_TIME - 10] };
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.calledWith(Router._storage.set, "messageImpressions", result);
+ assert.deepEqual(Router.state.messageImpressions, result);
+ });
+ it("should clear messageImpressions if they are not properly formatted", async () => {
+ const messages = [{ id: "foo", frequency: { lifetime: 10 } }];
+ // this is impromperly formatted since messageImpressions are supposed to be an array
+ messageImpressions = { foo: 0 };
+ const result = {};
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.calledWith(Router._storage.set, "messageImpressions", result);
+ assert.deepEqual(Router.state.messageImpressions, result);
+ });
+ it("should not clear messageImpressions for messages which do exist in state.messages", async () => {
+ const messages = [
+ { id: "foo", frequency: { lifetime: 10 } },
+ { id: "bar", frequency: { lifetime: 10 } },
+ ];
+ messageImpressions = { foo: [0], bar: [] };
+
+ await createRouterAndInit([
+ { id: "onboarding", type: "local", messages, enabled: true },
+ ]);
+ assert.notCalled(Router._storage.set);
+ assert.deepEqual(Router.state.messageImpressions, messageImpressions);
+ });
+ });
+ });
+
+ describe("#_onLocaleChanged", () => {
+ it("should call _maybeUpdateL10nAttachment in the handler", async () => {
+ sandbox.spy(Router, "_maybeUpdateL10nAttachment");
+ await Router._onLocaleChanged();
+
+ assert.calledOnce(Router._maybeUpdateL10nAttachment);
+ });
+ });
+
+ describe("#_maybeUpdateL10nAttachment", () => {
+ it("should update the l10n attachment if the locale was changed", async () => {
+ const getter = sandbox.stub();
+ getter.onFirstCall().returns("en-US");
+ getter.onSecondCall().returns("fr");
+ sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter);
+ const provider = {
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ };
+ await createRouterAndInit([provider]);
+ sandbox.spy(Router, "setState");
+ Router.loadMessagesFromAllProviders.resetHistory();
+
+ await Router._maybeUpdateL10nAttachment();
+
+ assert.calledWith(Router.setState, {
+ localeInUse: "fr",
+ providers: [
+ {
+ id: "cfr",
+ enabled: true,
+ type: "remote-settings",
+ collection: "cfr",
+ lastUpdated: undefined,
+ errors: [],
+ },
+ ],
+ });
+ assert.calledOnce(Router.loadMessagesFromAllProviders);
+ });
+ it("should not update the l10n attachment if the provider doesn't need l10n attachment", async () => {
+ const getter = sandbox.stub();
+ getter.onFirstCall().returns("en-US");
+ getter.onSecondCall().returns("fr");
+ sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter);
+ const provider = {
+ id: "localProvider",
+ enabled: true,
+ type: "local",
+ };
+ await createRouterAndInit([provider]);
+ Router.loadMessagesFromAllProviders.resetHistory();
+ sandbox.spy(Router, "setState");
+
+ await Router._maybeUpdateL10nAttachment();
+
+ assert.notCalled(Router.setState);
+ assert.notCalled(Router.loadMessagesFromAllProviders);
+ });
+ });
+ describe("#observe", () => {
+ it("should reload l10n for CFRPageActions when the `USE_REMOTE_L10N_PREF` pref is changed", () => {
+ sandbox.spy(CFRPageActions, "reloadL10n");
+
+ Router.observe("", "", USE_REMOTE_L10N_PREF);
+
+ assert.calledOnce(CFRPageActions.reloadL10n);
+ });
+ it("should not react to other pref changes", () => {
+ sandbox.spy(CFRPageActions, "reloadL10n");
+
+ Router.observe("", "", "foo");
+
+ assert.notCalled(CFRPageActions.reloadL10n);
+ });
+ });
+ describe("#loadAllMessageGroups", () => {
+ it("should disable the group if the pref is false", async () => {
+ sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false);
+ sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([
+ {
+ id: "provider-group",
+ enabled: true,
+ type: "remote",
+ userPreferences: ["cfrAddons"],
+ },
+ ]);
+ await Router.setState({
+ providers: [
+ {
+ id: "message-groups",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "provider-group");
+
+ assert.ok(group);
+ assert.propertyVal(group, "enabled", false);
+ });
+ it("should enable the group if at least one pref is true", async () => {
+ sandbox
+ .stub(ASRouterPreferences, "getUserPreference")
+ .withArgs("cfrAddons")
+ .returns(false)
+ .withArgs("cfrFeatures")
+ .returns(true);
+ sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([
+ {
+ id: "provider-group",
+ enabled: true,
+ type: "remote",
+ userPreferences: ["cfrAddons", "cfrFeatures"],
+ },
+ ]);
+ await Router.setState({
+ providers: [
+ {
+ id: "message-groups",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "provider-group");
+
+ assert.ok(group);
+ assert.propertyVal(group, "enabled", true);
+ });
+ it("should be keep the group disabled if disabled is true", async () => {
+ sandbox.stub(ASRouterPreferences, "getUserPreference").returns(true);
+ sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([
+ {
+ id: "provider-group",
+ enabled: false,
+ type: "remote",
+ userPreferences: ["cfrAddons"],
+ },
+ ]);
+ await Router.setState({
+ providers: [
+ {
+ id: "message-groups",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "provider-group");
+
+ assert.ok(group);
+ assert.propertyVal(group, "enabled", false);
+ });
+ it("should keep local groups unchanged if provider doesn't require an update", async () => {
+ sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false);
+ sandbox.stub(MessageLoaderUtils, "_loadDataForProvider");
+ await Router.setState({
+ groups: [
+ {
+ id: "cfr",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "cfr");
+
+ assert.ok(group);
+ assert.propertyVal(group, "enabled", true);
+ // Because it should not have updated
+ assert.notCalled(MessageLoaderUtils._loadDataForProvider);
+ });
+ it("should update local groups on pref change (no RS update)", async () => {
+ sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false);
+ sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false);
+ await Router.setState({
+ groups: [
+ {
+ id: "cfr",
+ enabled: true,
+ collection: "collection",
+ type: "remote-settings",
+ userPreferences: ["cfrAddons"],
+ },
+ ],
+ });
+
+ await Router.loadAllMessageGroups();
+
+ const group = Router.state.groups.find(g => g.id === "cfr");
+
+ assert.ok(group);
+ // Pref changed, updated the group state
+ assert.propertyVal(group, "enabled", false);
+ });
+ });
+ describe("unblockAll", () => {
+ it("Clears the message block list and returns the state value", async () => {
+ await Router.setState({ messageBlockList: ["one", "two", "three"] });
+ assert.equal(Router.state.messageBlockList.length, 3);
+ const state = await Router.unblockAll();
+ assert.equal(Router.state.messageBlockList.length, 0);
+ assert.equal(state.messageBlockList.length, 0);
+ });
+ });
+ describe("#loadMessagesForProvider", () => {
+ it("should fetch messages from the ExperimentAPI", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["spotlight"],
+ };
+
+ await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.calledOnce(global.NimbusFeatures.spotlight.getAllVariables);
+ assert.calledOnce(global.ExperimentAPI.getExperimentMetaData);
+ assert.calledWithExactly(global.ExperimentAPI.getExperimentMetaData, {
+ featureId: "spotlight",
+ });
+ });
+ it("should handle the case of no experiments in the ExperimentAPI", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["infobar"],
+ };
+
+ global.ExperimentAPI.getExperiment.returns(null);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.lengthOf(result.messages, 0);
+ });
+ it("should normally load ExperimentAPI messages", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["infobar"],
+ };
+ const enrollment = {
+ branch: {
+ slug: "branch01",
+ infobar: {
+ featureId: "infobar",
+ value: { id: "id01", trigger: { id: "openURL" } },
+ },
+ },
+ };
+
+ global.NimbusFeatures.infobar.getAllVariables.returns(
+ enrollment.branch.infobar.value
+ );
+ global.ExperimentAPI.getExperimentMetaData.returns({
+ branch: { slug: enrollment.branch.slug },
+ });
+ global.ExperimentAPI.getAllBranches.returns([
+ enrollment.branch,
+ {
+ slug: "control",
+ infobar: {
+ featureId: "infobar",
+ value: null,
+ },
+ },
+ ]);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.lengthOf(result.messages, 1);
+ });
+ it("should skip disabled features and not load the messages", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["cfr"],
+ };
+
+ global.NimbusFeatures.cfr.getAllVariables.returns(null);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.lengthOf(result.messages, 0);
+ });
+ it("should fetch branches with trigger", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["cfr"],
+ };
+ const enrollment = {
+ slug: "exp01",
+ branch: {
+ slug: "branch01",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id01", trigger: { id: "openURL" } },
+ },
+ },
+ };
+
+ global.NimbusFeatures.cfr.getAllVariables.returns(
+ enrollment.branch.cfr.value
+ );
+ global.ExperimentAPI.getExperimentMetaData.returns({
+ slug: enrollment.slug,
+ active: true,
+ branch: { slug: enrollment.branch.slug },
+ });
+ global.ExperimentAPI.getAllBranches.resolves([
+ enrollment.branch,
+ {
+ slug: "branch02",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id02", trigger: { id: "openURL" } },
+ },
+ },
+ {
+ // This branch should not be loaded as it doesn't have the trigger
+ slug: "branch03",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id03" },
+ },
+ },
+ ]);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.equal(result.messages.length, 2);
+ assert.equal(result.messages[0].id, "id01");
+ assert.equal(result.messages[1].id, "id02");
+ assert.equal(result.messages[1].experimentSlug, "exp01");
+ assert.equal(result.messages[1].branchSlug, "branch02");
+ assert.deepEqual(result.messages[1].forReachEvent, {
+ sent: false,
+ group: "cfr",
+ });
+ });
+ it("should fetch branches with trigger even if enrolled branch is disabled", async () => {
+ const args = {
+ type: "remote-experiments",
+ featureIds: ["cfr"],
+ };
+ const enrollment = {
+ slug: "exp01",
+ branch: {
+ slug: "branch01",
+ cfr: {
+ featureId: "cfr",
+ value: {},
+ },
+ },
+ };
+
+ // Nedds to match the `featureIds` value to return an enrollment
+ // for that feature
+ global.NimbusFeatures.cfr.getAllVariables.returns(
+ enrollment.branch.cfr.value
+ );
+ global.ExperimentAPI.getExperimentMetaData.returns({
+ slug: enrollment.slug,
+ active: true,
+ branch: { slug: enrollment.branch.slug },
+ });
+ global.ExperimentAPI.getAllBranches.resolves([
+ enrollment.branch,
+ {
+ slug: "branch02",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id02", trigger: { id: "openURL" } },
+ },
+ },
+ {
+ // This branch should not be loaded as it doesn't have the trigger
+ slug: "branch03",
+ cfr: {
+ featureId: "cfr",
+ value: { id: "id03" },
+ },
+ },
+ ]);
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(args);
+
+ assert.equal(result.messages.length, 1);
+ assert.equal(result.messages[0].id, "id02");
+ assert.equal(result.messages[0].experimentSlug, "exp01");
+ assert.equal(result.messages[0].branchSlug, "branch02");
+ assert.deepEqual(result.messages[0].forReachEvent, {
+ sent: false,
+ group: "cfr",
+ });
+ });
+ });
+ describe("#_remoteSettingsLoader", () => {
+ let provider;
+ let spy;
+ beforeEach(() => {
+ provider = {
+ id: "cfr",
+ collection: "cfr",
+ };
+ sandbox
+ .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
+ .resolves([{ id: "message_1" }]);
+ spy = sandbox.spy();
+ global.Downloader.prototype.downloadToDisk = spy;
+ });
+ it("should be called with the expected dir path", async () => {
+ const dlSpy = sandbox.spy(global, "Downloader");
+
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.calledWith(
+ dlSpy,
+ "main",
+ "ms-language-packs",
+ "browser",
+ "newtab"
+ );
+ });
+ it("should allow fetch for known locales", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.calledOnce(spy);
+ });
+ it("should fallback to 'en-US' for locale 'und' ", async () => {
+ sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => "und");
+ const getRecordSpy = sandbox.spy(
+ global.KintoHttpClient.prototype,
+ "getRecord"
+ );
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.ok(getRecordSpy.args[0][0].includes("en-US"));
+ assert.calledOnce(spy);
+ });
+ it("should fallback to 'ja-JP-mac' for locale 'ja-JP-macos'", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "ja-JP-macos");
+ const getRecordSpy = sandbox.spy(
+ global.KintoHttpClient.prototype,
+ "getRecord"
+ );
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.ok(getRecordSpy.args[0][0].includes("ja-JP-mac"));
+ assert.calledOnce(spy);
+ });
+ it("should not allow fetch for unsupported locales", async () => {
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "unkown");
+
+ await MessageLoaderUtils._remoteSettingsLoader(provider, {});
+
+ assert.notCalled(spy);
+ });
+ });
+ describe("#resetMessageState", () => {
+ it("should reset all message impressions", async () => {
+ await Router.setState({
+ messages: [{ id: "1" }, { id: "2" }],
+ });
+ await Router.setState({
+ messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] },
+ }); // Add impressions for test messages
+ let impressions = Object.values(Router.state.messageImpressions);
+ assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions
+
+ Router.resetMessageState();
+ impressions = Object.values(Router.state.messageImpressions);
+
+ assert.isEmpty(impressions.filter(i => i.length)); // Both messages now have zero impressions
+ assert.calledWithExactly(Router._storage.set, "messageImpressions", {
+ 1: [],
+ 2: [],
+ });
+ });
+ });
+ describe("#resetGroupsState", () => {
+ it("should reset all group impressions", async () => {
+ await Router.setState({
+ groups: [{ id: "1" }, { id: "2" }],
+ });
+ await Router.setState({
+ groupImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] },
+ }); // Add impressions for test groups
+ let impressions = Object.values(Router.state.groupImpressions);
+ assert.equal(impressions.filter(i => i.length).length, 2); // Both groups have impressions
+
+ Router.resetGroupsState();
+ impressions = Object.values(Router.state.groupImpressions);
+
+ assert.isEmpty(impressions.filter(i => i.length)); // Both groups now have zero impressions
+ assert.calledWithExactly(Router._storage.set, "groupImpressions", {
+ 1: [],
+ 2: [],
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js
new file mode 100644
index 0000000000..346f0e02f3
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js
@@ -0,0 +1,74 @@
+/*eslint max-nested-callbacks: ["error", 10]*/
+import { ASRouterChild } from "actors/ASRouterChild.sys.mjs";
+import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("ASRouterChild", () => {
+ let asRouterChild = null;
+ let globals = null;
+ let overrider = null;
+ let sandbox = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = {
+ Cu: {
+ cloneInto: sandbox.stub().returns(Promise.resolve()),
+ },
+ };
+ overrider = new GlobalOverrider();
+ overrider.set(globals);
+ asRouterChild = new ASRouterChild();
+ asRouterChild.telemetry = {
+ sendTelemetry: sandbox.stub(),
+ };
+ sandbox.stub(asRouterChild, "sendAsyncMessage");
+ sandbox.stub(asRouterChild, "sendQuery").returns(Promise.resolve());
+ });
+ afterEach(() => {
+ sandbox.restore();
+ overrider.restore();
+ asRouterChild = null;
+ });
+ describe("asRouterMessage", () => {
+ describe("uses sendAsyncMessage for types that don't need an async response", () => {
+ [
+ msg.DISABLE_PROVIDER,
+ msg.ENABLE_PROVIDER,
+ msg.EXPIRE_QUERY_CACHE,
+ msg.FORCE_WHATSNEW_PANEL,
+ msg.IMPRESSION,
+ msg.RESET_PROVIDER_PREF,
+ msg.SET_PROVIDER_USER_PREF,
+ msg.USER_ACTION,
+ ].forEach(type => {
+ it(`type ${type}`, () => {
+ asRouterChild.asRouterMessage({
+ type,
+ data: {
+ something: 1,
+ },
+ });
+ sandbox.assert.calledOnce(asRouterChild.sendAsyncMessage);
+ sandbox.assert.calledWith(asRouterChild.sendAsyncMessage, type, {
+ something: 1,
+ });
+ });
+ });
+ });
+ describe("use sends messages that need a response using sendQuery", () => {
+ it("NEWTAB_MESSAGE_REQUEST", () => {
+ const type = msg.NEWTAB_MESSAGE_REQUEST;
+ asRouterChild.asRouterMessage({
+ type,
+ data: {
+ something: 1,
+ },
+ });
+ sandbox.assert.calledOnce(asRouterChild.sendQuery);
+ sandbox.assert.calledWith(asRouterChild.sendQuery, type, {
+ something: 1,
+ });
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js
new file mode 100644
index 0000000000..938c85d7de
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js
@@ -0,0 +1,153 @@
+/*eslint max-nested-callbacks: ["error", 10]*/
+import { ASRouterNewTabHook } from "lib/ASRouterNewTabHook.sys.mjs";
+
+describe("ASRouterNewTabHook", () => {
+ let sandbox = null;
+ let initParams = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ initParams = {
+ router: {
+ init: sandbox.stub().callsFake(() => {
+ // Fake the initialization
+ initParams.router.initialized = true;
+ }),
+ uninit: sandbox.stub(),
+ },
+ messageHandler: {
+ handleCFRAction: {},
+ handleTelemetry: {},
+ },
+ createStorage: () => Promise.resolve({}),
+ };
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ describe("ASRouterNewTabHook", () => {
+ describe("getInstance", () => {
+ it("awaits createInstance and router init before returning instance", async () => {
+ const getInstanceCall = sandbox.spy();
+ const waitForInstance =
+ ASRouterNewTabHook.getInstance().then(getInstanceCall);
+ await ASRouterNewTabHook.createInstance(initParams);
+ await waitForInstance;
+ assert.callOrder(initParams.router.init, getInstanceCall);
+ });
+ });
+ describe("createInstance", () => {
+ it("calls router init", async () => {
+ await ASRouterNewTabHook.createInstance(initParams);
+ assert.calledOnce(initParams.router.init);
+ });
+ it("only calls router init once", async () => {
+ initParams.router.init.callsFake(() => {
+ initParams.router.initialized = true;
+ });
+ await ASRouterNewTabHook.createInstance(initParams);
+ await ASRouterNewTabHook.createInstance(initParams);
+ assert.calledOnce(initParams.router.init);
+ });
+ });
+ describe("destroy", () => {
+ it("disconnects new tab, uninits ASRouter, and destroys instance", async () => {
+ await ASRouterNewTabHook.createInstance(initParams);
+ const instance = await ASRouterNewTabHook.getInstance();
+ const destroy = instance.destroy.bind(instance);
+ sandbox.stub(instance, "destroy").callsFake(destroy);
+ ASRouterNewTabHook.destroy();
+ assert.calledOnce(initParams.router.uninit);
+ assert.calledOnce(instance.destroy);
+ assert.isNotNull(instance);
+ assert.isNull(instance._newTabMessageHandler);
+ });
+ });
+ describe("instance", () => {
+ let routerParams = null;
+ let messageHandler = null;
+ let instance = null;
+ beforeEach(async () => {
+ messageHandler = {
+ clearChildMessages: sandbox.stub().resolves(),
+ clearChildProviders: sandbox.stub().resolves(),
+ updateAdminState: sandbox.stub().resolves(),
+ };
+ initParams.router.init.callsFake(params => {
+ routerParams = params;
+ });
+ await ASRouterNewTabHook.createInstance(initParams);
+ instance = await ASRouterNewTabHook.getInstance();
+ });
+ describe("connect", () => {
+ it("before connection messageHandler methods are not called", async () => {
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["snippets"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.notCalled(messageHandler.clearChildMessages);
+ assert.notCalled(messageHandler.clearChildProviders);
+ assert.notCalled(messageHandler.updateAdminState);
+ });
+ it("after connect updateAdminState and clearChildMessages calls are forwarded to handler", async () => {
+ instance.connect(messageHandler);
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["snippets"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.called(messageHandler.clearChildMessages);
+ assert.called(messageHandler.clearChildProviders);
+ assert.called(messageHandler.updateAdminState);
+ });
+ it("calls from before connection are dropped", async () => {
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["snippets"]);
+ routerParams.updateAdminState({ messages: {} });
+ instance.connect(messageHandler);
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["snippets"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.calledOnce(messageHandler.clearChildMessages);
+ assert.calledOnce(messageHandler.clearChildProviders);
+ assert.calledOnce(messageHandler.updateAdminState);
+ });
+ });
+ describe("disconnect", () => {
+ it("calls after disconnect are dropped", async () => {
+ instance.connect(messageHandler);
+ instance.disconnect();
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["snippets"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.notCalled(messageHandler.clearChildMessages);
+ assert.notCalled(messageHandler.clearChildProviders);
+ assert.notCalled(messageHandler.updateAdminState);
+ });
+ it("only calls from when there is a connection are forwarded", async () => {
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["foo"]);
+ routerParams.updateAdminState({ messages: {} });
+ instance.connect(messageHandler);
+ routerParams.clearChildMessages([200]);
+ routerParams.clearChildProviders(["bar"]);
+ routerParams.updateAdminState({
+ messages: {
+ data: "accept",
+ },
+ });
+ instance.disconnect();
+ routerParams.clearChildMessages([1]);
+ routerParams.clearChildProviders(["foo"]);
+ routerParams.updateAdminState({ messages: {} });
+ assert.calledOnce(messageHandler.clearChildMessages);
+ assert.calledOnce(messageHandler.clearChildProviders);
+ assert.calledOnce(messageHandler.updateAdminState);
+ assert.calledWith(messageHandler.clearChildMessages, [200]);
+ assert.calledWith(messageHandler.clearChildProviders, ["bar"]);
+ assert.calledWith(messageHandler.updateAdminState, {
+ messages: {
+ data: "accept",
+ },
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js
new file mode 100644
index 0000000000..1b494bbe0e
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js
@@ -0,0 +1,106 @@
+import { ASRouterParent } from "actors/ASRouterParent.sys.mjs";
+import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs";
+
+describe("ASRouterParent", () => {
+ let asRouterParent = null;
+ let sandbox = null;
+ let handleMessage = null;
+ let tabs = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ handleMessage = sandbox.stub().resolves("handle-message-result");
+ ASRouterParent.nextTabId = 1;
+ const methods = {
+ destroy: sandbox.stub(),
+ size: 1,
+ messageAll: sandbox.stub().resolves(),
+ registerActor: sandbox.stub(),
+ unregisterActor: sandbox.stub(),
+ loadingMessageHandler: Promise.resolve({
+ handleMessage,
+ }),
+ };
+ tabs = {
+ methods,
+ factory: sandbox.stub().returns(methods),
+ };
+ asRouterParent = new ASRouterParent({ tabsFactory: tabs.factory });
+ ASRouterParent.tabs = tabs.methods;
+ asRouterParent.browsingContext = {
+ embedderElement: {
+ getAttribute: () => true,
+ },
+ };
+ asRouterParent.tabId = ASRouterParent.nextTabId;
+ });
+ afterEach(() => {
+ sandbox.restore();
+ asRouterParent = null;
+ });
+ describe("actorCreated", () => {
+ it("after ASRouterTabs is instanced", () => {
+ asRouterParent.actorCreated();
+ assert.equal(asRouterParent.tabId, 2);
+ assert.notCalled(tabs.factory);
+ assert.calledOnce(tabs.methods.registerActor);
+ });
+ it("before ASRouterTabs is instanced", () => {
+ ASRouterParent.tabs = null;
+ ASRouterParent.nextTabId = 0;
+ asRouterParent.actorCreated();
+ assert.calledOnce(tabs.factory);
+ assert.isNotNull(ASRouterParent.tabs);
+ assert.equal(asRouterParent.tabId, 1);
+ });
+ });
+ describe("didDestroy", () => {
+ it("one still remains", () => {
+ ASRouterParent.tabs.size = 1;
+ asRouterParent.didDestroy();
+ assert.isNotNull(ASRouterParent.tabs);
+ assert.calledOnce(ASRouterParent.tabs.unregisterActor);
+ assert.notCalled(ASRouterParent.tabs.destroy);
+ });
+ it("none remain", () => {
+ ASRouterParent.tabs.size = 0;
+ const tabsCopy = ASRouterParent.tabs;
+ asRouterParent.didDestroy();
+ assert.isNull(ASRouterParent.tabs);
+ assert.calledOnce(tabsCopy.unregisterActor);
+ assert.calledOnce(tabsCopy.destroy);
+ });
+ });
+ describe("receiveMessage", async () => {
+ it("passes call to parentProcessMessageHandler and returns the result from handler", async () => {
+ const result = await asRouterParent.receiveMessage({
+ name: msg.BLOCK_MESSAGE_BY_ID,
+ data: { id: 1 },
+ });
+ assert.calledOnce(handleMessage);
+ assert.equal(result, "handle-message-result");
+ });
+ it("it messages all actors on BLOCK_MESSAGE_BY_ID messages", async () => {
+ const MESSAGE_ID = 1;
+ const result = await asRouterParent.receiveMessage({
+ name: msg.BLOCK_MESSAGE_BY_ID,
+ data: { id: MESSAGE_ID, campaign: "message-campaign" },
+ });
+ assert.calledOnce(handleMessage);
+ // Check that we correctly pass the tabId
+ assert.calledWithExactly(
+ handleMessage,
+ sinon.match.any,
+ sinon.match.any,
+ { id: sinon.match.number, browser: sinon.match.any }
+ );
+ assert.calledWithExactly(
+ ASRouterParent.tabs.messageAll,
+ "ClearMessages",
+ // When blocking an id the entire campaign is blocked
+ // and all other snippets become invalid
+ ["message-campaign"]
+ );
+ assert.equal(result, "handle-message-result");
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js
new file mode 100644
index 0000000000..1f35ab875e
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js
@@ -0,0 +1,428 @@
+import { ASRouterParentProcessMessageHandler } from "lib/ASRouterParentProcessMessageHandler.jsm";
+import { _ASRouter } from "lib/ASRouter.jsm";
+import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs";
+
+describe("ASRouterParentProcessMessageHandler", () => {
+ let handler = null;
+ let sandbox = null;
+ let config = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ const returnValue = { value: 1 };
+ const router = new _ASRouter();
+ [
+ "addImpression",
+ "addPreviewEndpoint",
+ "evaluateExpression",
+ "forceAttribution",
+ "forceWNPanel",
+ "closeWNPanel",
+ "forcePBWindow",
+ "resetGroupsState",
+ ].forEach(method => sandbox.stub(router, `${method}`).resolves());
+ [
+ "blockMessageById",
+ "loadMessagesFromAllProviders",
+ "sendNewTabMessage",
+ "sendTriggerMessage",
+ "routeCFRMessage",
+ "setMessageById",
+ "updateTargetingParameters",
+ "unblockMessageById",
+ "unblockAll",
+ ].forEach(method =>
+ sandbox.stub(router, `${method}`).resolves(returnValue)
+ );
+ router._storage = {
+ set: sandbox.stub().resolves(),
+ get: sandbox.stub().resolves(),
+ };
+ sandbox.stub(router, "setState").callsFake(callback => {
+ if (typeof callback === "function") {
+ callback({
+ messageBlockList: [
+ {
+ id: 0,
+ },
+ {
+ id: 1,
+ },
+ {
+ id: 2,
+ },
+ {
+ id: 3,
+ },
+ {
+ id: 4,
+ },
+ ],
+ });
+ }
+ return Promise.resolve(returnValue);
+ });
+ const preferences = {
+ enableOrDisableProvider: sandbox.stub(),
+ resetProviderPref: sandbox.stub(),
+ setUserPreference: sandbox.stub(),
+ };
+ const specialMessageActions = {
+ handleAction: sandbox.stub(),
+ };
+ const queryCache = {
+ expireAll: sandbox.stub(),
+ };
+ const sendTelemetry = sandbox.stub();
+ config = {
+ router,
+ preferences,
+ specialMessageActions,
+ queryCache,
+ sendTelemetry,
+ };
+ handler = new ASRouterParentProcessMessageHandler(config);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ handler = null;
+ config = null;
+ });
+ describe("constructor", () => {
+ it("does not throw", () => {
+ assert.isNotNull(handler);
+ assert.isNotNull(config);
+ });
+ });
+ describe("handleCFRAction", () => {
+ it("non-telemetry type isn't sent to telemetry", () => {
+ handler.handleCFRAction({
+ type: msg.BLOCK_MESSAGE_BY_ID,
+ data: { id: 1 },
+ });
+ assert.notCalled(config.sendTelemetry);
+ assert.calledOnce(config.router.blockMessageById);
+ });
+ it("passes browser to handleMessage", async () => {
+ await handler.handleCFRAction(
+ {
+ type: msg.USER_ACTION,
+ data: { id: 1 },
+ },
+ { ownerGlobal: {} }
+ );
+ assert.notCalled(config.sendTelemetry);
+ assert.calledOnce(config.specialMessageActions.handleAction);
+ assert.calledWith(
+ config.specialMessageActions.handleAction,
+ { id: 1 },
+ { ownerGlobal: {} }
+ );
+ });
+ [
+ msg.AS_ROUTER_TELEMETRY_USER_EVENT,
+ msg.TOOLBAR_BADGE_TELEMETRY,
+ msg.TOOLBAR_PANEL_TELEMETRY,
+ msg.MOMENTS_PAGE_TELEMETRY,
+ msg.DOORHANGER_TELEMETRY,
+ ].forEach(type => {
+ it(`telemetry type "${type}" is sent to telemetry`, () => {
+ handler.handleCFRAction({
+ type,
+ data: { id: 1 },
+ });
+ assert.calledOnce(config.sendTelemetry);
+ assert.notCalled(config.router.blockMessageById);
+ });
+ });
+ });
+ describe("#handleMessage", () => {
+ it("#default: should throw for unknown msg types", () => {
+ handler.handleMessage("err").then(
+ () => assert.fail("It should not succeed"),
+ () => assert.ok(true)
+ );
+ });
+ describe("#AS_ROUTER_TELEMETRY_USER_EVENT", () => {
+ it("should route AS_ROUTER_TELEMETRY_USER_EVENT to handleTelemetry", async () => {
+ const data = { data: "foo" };
+ await handler.handleMessage(msg.AS_ROUTER_TELEMETRY_USER_EVENT, data);
+
+ assert.calledOnce(handler.handleTelemetry);
+ assert.calledWithExactly(handler.handleTelemetry, {
+ type: msg.AS_ROUTER_TELEMETRY_USER_EVENT,
+ data,
+ });
+ });
+ });
+ describe("BLOCK_MESSAGE_BY_ID action", () => {
+ it("with preventDismiss returns false", async () => {
+ const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, {
+ id: 1,
+ preventDismiss: true,
+ });
+ assert.calledOnce(config.router.blockMessageById);
+ assert.isFalse(result);
+ });
+ it("by default returns true", async () => {
+ const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, {
+ id: 1,
+ });
+ assert.calledOnce(config.router.blockMessageById);
+ assert.isTrue(result);
+ });
+ });
+ describe("USER_ACTION action", () => {
+ it("default calls SpecialMessageActions.handleAction", async () => {
+ await handler.handleMessage(
+ msg.USER_ACTION,
+ {
+ type: "SOMETHING",
+ },
+ { browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.specialMessageActions.handleAction);
+ assert.calledWith(
+ config.specialMessageActions.handleAction,
+ { type: "SOMETHING" },
+ { ownerGlobal: {} }
+ );
+ });
+ });
+ describe("IMPRESSION action", () => {
+ it("default calls addImpression", () => {
+ handler.handleMessage(msg.IMPRESSION, {
+ id: 1,
+ });
+ assert.calledOnce(config.router.addImpression);
+ });
+ });
+ describe("TRIGGER action", () => {
+ it("default calls sendTriggerMessage and returns state", async () => {
+ const result = await handler.handleMessage(
+ msg.TRIGGER,
+ {
+ trigger: { stuff: {} },
+ },
+ { id: 100, browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.sendTriggerMessage);
+ assert.calledWith(config.router.sendTriggerMessage, {
+ stuff: {},
+ tabId: 100,
+ browser: { ownerGlobal: {} },
+ });
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("NEWTAB_MESSAGE_REQUEST action", () => {
+ it("default calls sendNewTabMessage and returns state", async () => {
+ const result = await handler.handleMessage(
+ msg.NEWTAB_MESSAGE_REQUEST,
+ {
+ stuff: {},
+ },
+ { id: 100, browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.sendNewTabMessage);
+ assert.calledWith(config.router.sendNewTabMessage, {
+ stuff: {},
+ tabId: 100,
+ browser: { ownerGlobal: {} },
+ });
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("ADMIN_CONNECT_STATE action", () => {
+ it("with endpoint url calls addPreviewEndpoint, loadMessagesFromAllProviders, and returns state", async () => {
+ const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE, {
+ endpoint: {
+ url: "test",
+ },
+ });
+ assert.calledOnce(config.router.addPreviewEndpoint);
+ assert.calledOnce(config.router.loadMessagesFromAllProviders);
+ assert.deepEqual(result, { value: 1 });
+ });
+ it("default returns state", async () => {
+ const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE);
+ assert.calledOnce(config.router.updateTargetingParameters);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("UNBLOCK_MESSAGE_BY_ID action", () => {
+ it("default calls unblockMessageById", async () => {
+ const result = await handler.handleMessage(msg.UNBLOCK_MESSAGE_BY_ID, {
+ id: 1,
+ });
+ assert.calledOnce(config.router.unblockMessageById);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("UNBLOCK_ALL action", () => {
+ it("default calls unblockAll", async () => {
+ const result = await handler.handleMessage(msg.UNBLOCK_ALL);
+ assert.calledOnce(config.router.unblockAll);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("BLOCK_BUNDLE action", () => {
+ it("default calls unblockMessageById", async () => {
+ const result = await handler.handleMessage(msg.BLOCK_BUNDLE, {
+ bundle: [
+ {
+ id: 8,
+ },
+ {
+ id: 13,
+ },
+ ],
+ });
+ assert.calledOnce(config.router.blockMessageById);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("UNBLOCK_BUNDLE action", () => {
+ it("default calls setState", async () => {
+ const result = await handler.handleMessage(msg.UNBLOCK_BUNDLE, {
+ bundle: [
+ {
+ id: 1,
+ },
+ {
+ id: 3,
+ },
+ ],
+ });
+ assert.calledOnce(config.router.setState);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("DISABLE_PROVIDER action", () => {
+ it("default calls ASRouterPreferences.enableOrDisableProvider", () => {
+ handler.handleMessage(msg.DISABLE_PROVIDER, {});
+ assert.calledOnce(config.preferences.enableOrDisableProvider);
+ });
+ });
+ describe("ENABLE_PROVIDER action", () => {
+ it("default calls ASRouterPreferences.enableOrDisableProvider", () => {
+ handler.handleMessage(msg.ENABLE_PROVIDER, {});
+ assert.calledOnce(config.preferences.enableOrDisableProvider);
+ });
+ });
+ describe("EVALUATE_JEXL_EXPRESSION action", () => {
+ it("default calls evaluateExpression", () => {
+ handler.handleMessage(msg.EVALUATE_JEXL_EXPRESSION, {});
+ assert.calledOnce(config.router.evaluateExpression);
+ });
+ });
+ describe("EXPIRE_QUERY_CACHE action", () => {
+ it("default calls QueryCache.expireAll", () => {
+ handler.handleMessage(msg.EXPIRE_QUERY_CACHE);
+ assert.calledOnce(config.queryCache.expireAll);
+ });
+ });
+ describe("FORCE_ATTRIBUTION action", () => {
+ it("default calls forceAttribution", () => {
+ handler.handleMessage(msg.FORCE_ATTRIBUTION, {});
+ assert.calledOnce(config.router.forceAttribution);
+ });
+ });
+ describe("FORCE_WHATSNEW_PANEL action", () => {
+ it("default calls forceWNPanel", () => {
+ handler.handleMessage(
+ msg.FORCE_WHATSNEW_PANEL,
+ {},
+ { browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.forceWNPanel);
+ assert.calledWith(config.router.forceWNPanel, { ownerGlobal: {} });
+ });
+ });
+ describe("CLOSE_WHATSNEW_PANEL action", () => {
+ it("default calls closeWNPanel", () => {
+ handler.handleMessage(
+ msg.CLOSE_WHATSNEW_PANEL,
+ {},
+ { browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.closeWNPanel);
+ assert.calledWith(config.router.closeWNPanel, { ownerGlobal: {} });
+ });
+ });
+ describe("FORCE_PRIVATE_BROWSING_WINDOW action", () => {
+ it("default calls forcePBWindow", () => {
+ handler.handleMessage(
+ msg.FORCE_PRIVATE_BROWSING_WINDOW,
+ {},
+ { browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.forcePBWindow);
+ assert.calledWith(config.router.forcePBWindow, { ownerGlobal: {} });
+ });
+ });
+ describe("MODIFY_MESSAGE_JSON action", () => {
+ it("default calls routeCFRMessage", async () => {
+ const result = await handler.handleMessage(
+ msg.MODIFY_MESSAGE_JSON,
+ {
+ content: {
+ text: "something",
+ },
+ },
+ { browser: { ownerGlobal: {} }, id: 100 }
+ );
+ assert.calledOnce(config.router.routeCFRMessage);
+ assert.calledWith(
+ config.router.routeCFRMessage,
+ { text: "something" },
+ { ownerGlobal: {} },
+ { content: { text: "something" } },
+ true
+ );
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("OVERRIDE_MESSAGE action", () => {
+ it("default calls setMessageById", async () => {
+ const result = await handler.handleMessage(
+ msg.OVERRIDE_MESSAGE,
+ {
+ id: 1,
+ },
+ { id: 100, browser: { ownerGlobal: {} } }
+ );
+ assert.calledOnce(config.router.setMessageById);
+ assert.calledWith(config.router.setMessageById, { id: 1 }, true, {
+ ownerGlobal: {},
+ });
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ describe("RESET_PROVIDER_PREF action", () => {
+ it("default calls ASRouterPreferences.resetProviderPref", () => {
+ handler.handleMessage(msg.RESET_PROVIDER_PREF);
+ assert.calledOnce(config.preferences.resetProviderPref);
+ });
+ });
+ describe("SET_PROVIDER_USER_PREF action", () => {
+ it("default calls ASRouterPreferences.setUserPreference", () => {
+ handler.handleMessage(msg.SET_PROVIDER_USER_PREF, {
+ id: 1,
+ value: true,
+ });
+ assert.calledOnce(config.preferences.setUserPreference);
+ assert.calledWith(config.preferences.setUserPreference, 1, true);
+ });
+ });
+ describe("RESET_GROUPS_STATE action", () => {
+ it("default calls resetGroupsState, loadMessagesFromAllProviders, and returns state", async () => {
+ const result = await handler.handleMessage(msg.RESET_GROUPS_STATE, {
+ property: "value",
+ });
+ assert.calledOnce(config.router.resetGroupsState);
+ assert.calledOnce(config.router.loadMessagesFromAllProviders);
+ assert.deepEqual(result, { value: 1 });
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js
new file mode 100644
index 0000000000..3ad759d6b9
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js
@@ -0,0 +1,491 @@
+import {
+ _ASRouterPreferences,
+ ASRouterPreferences as ASRouterPreferencesSingleton,
+ TEST_PROVIDERS,
+} from "lib/ASRouterPreferences.jsm";
+const FAKE_PROVIDERS = [{ id: "foo" }, { id: "bar" }];
+
+const PROVIDER_PREF_BRANCH =
+ "browser.newtabpage.activity-stream.asrouter.providers.";
+const DEVTOOLS_PREF =
+ "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled";
+const SNIPPETS_USER_PREF = "browser.newtabpage.activity-stream.feeds.snippets";
+const CFR_USER_PREF_ADDONS =
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons";
+const CFR_USER_PREF_FEATURES =
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features";
+
+/** NUMBER_OF_PREFS_TO_OBSERVE includes:
+ * 1. asrouter.providers. pref branch
+ * 2. asrouter.devtoolsEnabled
+ * 3. browser.newtabpage.activity-stream.feeds.snippets (user preference - snippets)
+ * 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons (user preference - cfr)
+ * 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features (user preference - cfr)
+ * 5. services.sync.username
+ */
+const NUMBER_OF_PREFS_TO_OBSERVE = 6;
+
+describe("ASRouterPreferences", () => {
+ let ASRouterPreferences;
+ let sandbox;
+ let addObserverStub;
+ let stringPrefStub;
+ let boolPrefStub;
+ let resetStub;
+ let hasUserValueStub;
+ let childListStub;
+ let setStringPrefStub;
+
+ beforeEach(() => {
+ ASRouterPreferences = new _ASRouterPreferences();
+
+ sandbox = sinon.createSandbox();
+ addObserverStub = sandbox.stub(global.Services.prefs, "addObserver");
+ stringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
+ resetStub = sandbox.stub(global.Services.prefs, "clearUserPref");
+ setStringPrefStub = sandbox.stub(global.Services.prefs, "setStringPref");
+ FAKE_PROVIDERS.forEach(provider => {
+ stringPrefStub
+ .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`)
+ .returns(JSON.stringify(provider));
+ });
+
+ boolPrefStub = sandbox
+ .stub(global.Services.prefs, "getBoolPref")
+ .returns(false);
+
+ hasUserValueStub = sandbox
+ .stub(global.Services.prefs, "prefHasUserValue")
+ .returns(false);
+
+ childListStub = sandbox.stub(global.Services.prefs, "getChildList");
+ childListStub
+ .withArgs(PROVIDER_PREF_BRANCH)
+ .returns(
+ FAKE_PROVIDERS.map(provider => `${PROVIDER_PREF_BRANCH}${provider.id}`)
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ function getPrefNameForProvider(providerId) {
+ return `${PROVIDER_PREF_BRANCH}${providerId}`;
+ }
+
+ function setPrefForProvider(providerId, value) {
+ stringPrefStub
+ .withArgs(getPrefNameForProvider(providerId))
+ .returns(JSON.stringify(value));
+ }
+
+ it("ASRouterPreferences should be an instance of _ASRouterPreferences", () => {
+ assert.instanceOf(ASRouterPreferencesSingleton, _ASRouterPreferences);
+ });
+ describe("#init", () => {
+ it("should set ._initialized to true", () => {
+ ASRouterPreferences.init();
+ assert.isTrue(ASRouterPreferences._initialized);
+ });
+ it("should migrate the provider prefs", () => {
+ ASRouterPreferences.uninit();
+ // Should be migrated because they contain bucket and not collection
+ const MIGRATE_PROVIDERS = [
+ { id: "baz", bucket: "buk" },
+ { id: "qux", bucket: "buk" },
+ ];
+ // Should be cleared to defaults because it throws on setStringPref
+ const ERROR_PROVIDER = { id: "err", bucket: "buk" };
+ // Should not be migrated because, although modified, it lacks bucket
+ const MODIFIED_SAFE_PROVIDER = { id: "safe" };
+ const ALL_PROVIDERS = [
+ ...MIGRATE_PROVIDERS,
+ ...FAKE_PROVIDERS, // Should not be migrated because they're unmodified
+ MODIFIED_SAFE_PROVIDER,
+ ERROR_PROVIDER,
+ ];
+ // The migrator should attempt to read prefs for all of these providers
+ const TRY_PROVIDERS = [
+ ...MIGRATE_PROVIDERS,
+ MODIFIED_SAFE_PROVIDER,
+ ERROR_PROVIDER,
+ ];
+
+ // Update the full list of provider prefs
+ childListStub
+ .withArgs(PROVIDER_PREF_BRANCH)
+ .returns(
+ ALL_PROVIDERS.map(provider => getPrefNameForProvider(provider.id))
+ );
+ // Stub the pref values so the migrator can read them
+ ALL_PROVIDERS.forEach(provider => {
+ stringPrefStub
+ .withArgs(getPrefNameForProvider(provider.id))
+ .returns(JSON.stringify(provider));
+ });
+
+ // Consider these providers' prefs "modified"
+ TRY_PROVIDERS.forEach(provider => {
+ hasUserValueStub
+ .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`)
+ .returns(true);
+ });
+ // Spoof an error when trying to set the pref for this provider so we can
+ // test that the pref is gracefully reset on error
+ setStringPrefStub
+ .withArgs(getPrefNameForProvider(ERROR_PROVIDER.id))
+ .throws();
+
+ ASRouterPreferences.init();
+
+ // The migrator should have tried to check each pref for user modification
+ ALL_PROVIDERS.forEach(provider =>
+ assert.calledWith(hasUserValueStub, getPrefNameForProvider(provider.id))
+ );
+ // Test that we don't call getStringPref for providers that don't have a
+ // user-defined value
+ FAKE_PROVIDERS.forEach(provider =>
+ assert.neverCalledWith(
+ stringPrefStub,
+ getPrefNameForProvider(provider.id)
+ )
+ );
+ // But we do call it for providers that do have a user-defined value
+ TRY_PROVIDERS.forEach(provider =>
+ assert.calledWith(stringPrefStub, getPrefNameForProvider(provider.id))
+ );
+
+ // Test that we don't call setStringPref to migrate providers that don't
+ // have a bucket property
+ assert.neverCalledWith(
+ setStringPrefStub,
+ getPrefNameForProvider(MODIFIED_SAFE_PROVIDER.id)
+ );
+
+ /**
+ * For a given provider, return a sinon matcher that matches if the value
+ * looks like a migrated version of the original provider. Requires that:
+ * its id matches the original provider's id; it has no bucket; and its
+ * collection is set to the value of the original provider's bucket.
+ * @param {object} provider the provider object to compare to
+ * @returns {object} custom matcher object for sinon
+ */
+ function providerJsonMatches(provider) {
+ return sandbox.match(migrated => {
+ const parsed = JSON.parse(migrated);
+ return (
+ parsed.id === provider.id &&
+ !("bucket" in parsed) &&
+ parsed.collection === provider.bucket
+ );
+ });
+ }
+
+ // Test that we call setStringPref to migrate providers that have a bucket
+ // property and don't have a collection property
+ MIGRATE_PROVIDERS.forEach(provider =>
+ assert.calledWith(
+ setStringPrefStub,
+ getPrefNameForProvider(provider.id),
+ providerJsonMatches(provider) // Verify the migrated pref value
+ )
+ );
+
+ // Test that we clear the pref for providers that throw when we try to
+ // read or write them
+ assert.calledWith(resetStub, getPrefNameForProvider(ERROR_PROVIDER.id));
+ });
+ it(`should set ${NUMBER_OF_PREFS_TO_OBSERVE} observers and not re-initialize if already initialized`, () => {
+ ASRouterPreferences.init();
+ assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE);
+ ASRouterPreferences.init();
+ ASRouterPreferences.init();
+ assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE);
+ });
+ });
+ describe("#uninit", () => {
+ it("should set ._initialized to false", () => {
+ ASRouterPreferences.init();
+ ASRouterPreferences.uninit();
+ assert.isFalse(ASRouterPreferences._initialized);
+ });
+ it("should clear cached values for ._initialized, .devtoolsEnabled", () => {
+ ASRouterPreferences.init();
+ // trigger caching
+ // eslint-disable-next-line no-unused-vars
+ const result = [
+ ASRouterPreferences.providers,
+ ASRouterPreferences.devtoolsEnabled,
+ ];
+ assert.isNotNull(
+ ASRouterPreferences._providers,
+ "providers should not be null"
+ );
+ assert.isNotNull(
+ ASRouterPreferences._devtoolsEnabled,
+ "devtolosEnabled should not be null"
+ );
+
+ ASRouterPreferences.uninit();
+ assert.isNull(ASRouterPreferences._providers);
+ assert.isNull(ASRouterPreferences._devtoolsEnabled);
+ });
+ it("should clear all listeners and remove observers (only once)", () => {
+ const removeStub = sandbox.stub(global.Services.prefs, "removeObserver");
+ ASRouterPreferences.init();
+ ASRouterPreferences.addListener(() => {});
+ ASRouterPreferences.addListener(() => {});
+ assert.equal(ASRouterPreferences._callbacks.size, 2);
+ ASRouterPreferences.uninit();
+ // Tests to make sure we don't remove observers that weren't set
+ ASRouterPreferences.uninit();
+
+ assert.callCount(removeStub, NUMBER_OF_PREFS_TO_OBSERVE);
+ assert.calledWith(removeStub, PROVIDER_PREF_BRANCH);
+ assert.calledWith(removeStub, DEVTOOLS_PREF);
+ assert.isEmpty(ASRouterPreferences._callbacks);
+ });
+ });
+ describe(".providers", () => {
+ it("should return the value the first time .providers is accessed", () => {
+ ASRouterPreferences.init();
+
+ const result = ASRouterPreferences.providers;
+ assert.deepEqual(result, FAKE_PROVIDERS);
+ // once per pref
+ assert.calledTwice(stringPrefStub);
+ });
+ it("should return the cached value the second time .providers is accessed", () => {
+ ASRouterPreferences.init();
+ const [, secondCall] = [
+ ASRouterPreferences.providers,
+ ASRouterPreferences.providers,
+ ];
+
+ assert.deepEqual(secondCall, FAKE_PROVIDERS);
+ // once per pref
+ assert.calledTwice(stringPrefStub);
+ });
+ it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => {
+ // Intentionally not initialized
+ const [firstCall, secondCall] = [
+ ASRouterPreferences.providers,
+ ASRouterPreferences.providers,
+ ];
+
+ assert.deepEqual(firstCall, FAKE_PROVIDERS);
+ assert.deepEqual(secondCall, FAKE_PROVIDERS);
+ assert.callCount(stringPrefStub, 4);
+ });
+ it("should skip the pref without throwing if a pref is not parsable", () => {
+ stringPrefStub.withArgs(`${PROVIDER_PREF_BRANCH}foo`).returns("not json");
+ ASRouterPreferences.init();
+
+ assert.deepEqual(ASRouterPreferences.providers, [{ id: "bar" }]);
+ });
+ it("should include TEST_PROVIDERS if devtools is turned on", () => {
+ boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true);
+ ASRouterPreferences.init();
+
+ assert.deepEqual(ASRouterPreferences.providers, [
+ ...TEST_PROVIDERS,
+ ...FAKE_PROVIDERS,
+ ]);
+ });
+ });
+ describe(".devtoolsEnabled", () => {
+ it("should read the pref the first time .devtoolsEnabled is accessed", () => {
+ ASRouterPreferences.init();
+
+ const result = ASRouterPreferences.devtoolsEnabled;
+ assert.deepEqual(result, false);
+ assert.calledOnce(boolPrefStub);
+ });
+ it("should return the cached value the second time .devtoolsEnabled is accessed", () => {
+ ASRouterPreferences.init();
+ const [, secondCall] = [
+ ASRouterPreferences.devtoolsEnabled,
+ ASRouterPreferences.devtoolsEnabled,
+ ];
+
+ assert.deepEqual(secondCall, false);
+ assert.calledOnce(boolPrefStub);
+ });
+ it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => {
+ // Intentionally not initialized
+ const [firstCall, secondCall] = [
+ ASRouterPreferences.devtoolsEnabled,
+ ASRouterPreferences.devtoolsEnabled,
+ ];
+
+ assert.deepEqual(firstCall, false);
+ assert.deepEqual(secondCall, false);
+ assert.calledTwice(boolPrefStub);
+ });
+ });
+ describe("#getUserPreference(providerId)", () => {
+ it("should return the user preference for snippets", () => {
+ boolPrefStub.withArgs(SNIPPETS_USER_PREF).returns(true);
+ assert.isTrue(ASRouterPreferences.getUserPreference("snippets"));
+ });
+ });
+ describe("#getAllUserPreferences", () => {
+ it("should return all user preferences", () => {
+ boolPrefStub.withArgs(SNIPPETS_USER_PREF).returns(true);
+ boolPrefStub.withArgs(CFR_USER_PREF_ADDONS).returns(false);
+ boolPrefStub.withArgs(CFR_USER_PREF_FEATURES).returns(true);
+ const result = ASRouterPreferences.getAllUserPreferences();
+ assert.deepEqual(result, {
+ snippets: true,
+ cfrAddons: false,
+ cfrFeatures: true,
+ });
+ });
+ });
+ describe("#enableOrDisableProvider", () => {
+ it("should enable an existing provider if second param is true", () => {
+ setPrefForProvider("foo", { id: "foo", enabled: false });
+ assert.isFalse(ASRouterPreferences.providers[0].enabled);
+
+ ASRouterPreferences.enableOrDisableProvider("foo", true);
+
+ assert.calledWith(
+ setStringPrefStub,
+ getPrefNameForProvider("foo"),
+ JSON.stringify({ id: "foo", enabled: true })
+ );
+ });
+ it("should disable an existing provider if second param is false", () => {
+ setPrefForProvider("foo", { id: "foo", enabled: true });
+ assert.isTrue(ASRouterPreferences.providers[0].enabled);
+
+ ASRouterPreferences.enableOrDisableProvider("foo", false);
+
+ assert.calledWith(
+ setStringPrefStub,
+ getPrefNameForProvider("foo"),
+ JSON.stringify({ id: "foo", enabled: false })
+ );
+ });
+ it("should not throw if the id does not exist", () => {
+ assert.doesNotThrow(() => {
+ ASRouterPreferences.enableOrDisableProvider("does_not_exist", true);
+ });
+ });
+ it("should not throw if pref is not parseable", () => {
+ stringPrefStub
+ .withArgs(getPrefNameForProvider("foo"))
+ .returns("not valid");
+ assert.doesNotThrow(() => {
+ ASRouterPreferences.enableOrDisableProvider("foo", true);
+ });
+ });
+ });
+ describe("#setUserPreference", () => {
+ it("should do nothing if the pref doesn't exist", () => {
+ ASRouterPreferences.setUserPreference("foo", true);
+ assert.notCalled(boolPrefStub);
+ });
+ it("should set the given pref", () => {
+ const setStub = sandbox.stub(global.Services.prefs, "setBoolPref");
+ ASRouterPreferences.setUserPreference("snippets", true);
+ assert.calledWith(setStub, SNIPPETS_USER_PREF, true);
+ });
+ });
+ describe("#resetProviderPref", () => {
+ it("should reset the pref and user prefs", () => {
+ ASRouterPreferences.resetProviderPref();
+ FAKE_PROVIDERS.forEach(provider => {
+ assert.calledWith(resetStub, getPrefNameForProvider(provider.id));
+ });
+ assert.calledWith(resetStub, SNIPPETS_USER_PREF);
+ assert.calledWith(resetStub, CFR_USER_PREF_ADDONS);
+ assert.calledWith(resetStub, CFR_USER_PREF_FEATURES);
+ });
+ });
+ describe("observer, listeners", () => {
+ it("should invalidate .providers when the pref is changed", () => {
+ const testProvider = { id: "newstuff" };
+ const newProviders = [...FAKE_PROVIDERS, testProvider];
+
+ ASRouterPreferences.init();
+
+ assert.deepEqual(ASRouterPreferences.providers, FAKE_PROVIDERS);
+ stringPrefStub
+ .withArgs(getPrefNameForProvider(testProvider.id))
+ .returns(JSON.stringify(testProvider));
+ childListStub
+ .withArgs(PROVIDER_PREF_BRANCH)
+ .returns(
+ newProviders.map(provider => getPrefNameForProvider(provider.id))
+ );
+ ASRouterPreferences.observe(
+ null,
+ null,
+ getPrefNameForProvider(testProvider.id)
+ );
+
+ // Cache should be invalidated so we access the new value of the pref now
+ assert.deepEqual(ASRouterPreferences.providers, newProviders);
+ });
+ it("should invalidate .devtoolsEnabled and .providers when the pref is changed", () => {
+ ASRouterPreferences.init();
+
+ assert.isFalse(ASRouterPreferences.devtoolsEnabled);
+ boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true);
+ childListStub.withArgs(PROVIDER_PREF_BRANCH).returns([]);
+ ASRouterPreferences.observe(null, null, DEVTOOLS_PREF);
+
+ // Cache should be invalidated so we access the new value of the pref now
+ // Note that providers needs to be invalidated because devtools adds test content to it.
+ assert.isTrue(ASRouterPreferences.devtoolsEnabled);
+ assert.deepEqual(ASRouterPreferences.providers, TEST_PROVIDERS);
+ });
+ it("should call listeners added with .addListener", () => {
+ const callback1 = sinon.stub();
+ const callback2 = sinon.stub();
+ ASRouterPreferences.init();
+ ASRouterPreferences.addListener(callback1);
+ ASRouterPreferences.addListener(callback2);
+
+ ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo"));
+ assert.calledWith(callback1, getPrefNameForProvider("foo"));
+
+ ASRouterPreferences.observe(null, null, DEVTOOLS_PREF);
+ assert.calledWith(callback2, DEVTOOLS_PREF);
+ });
+ it("should not call listeners after they are removed with .removeListeners", () => {
+ const callback = sinon.stub();
+ ASRouterPreferences.init();
+ ASRouterPreferences.addListener(callback);
+
+ ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo"));
+ assert.calledWith(callback, getPrefNameForProvider("foo"));
+
+ callback.reset();
+ ASRouterPreferences.removeListener(callback);
+
+ ASRouterPreferences.observe(null, null, DEVTOOLS_PREF);
+ assert.notCalled(callback);
+ });
+ });
+ describe("#_transformPersonalizedCfrScores", () => {
+ it("should report JSON.parse errors", () => {
+ sandbox.stub(global.console, "error");
+
+ ASRouterPreferences._transformPersonalizedCfrScores("");
+
+ assert.calledOnce(global.console.error);
+ });
+ it("should return an object parsed from a string", () => {
+ const scores = { FOO: 3000, BAR: 4000 };
+ assert.deepEqual(
+ ASRouterPreferences._transformPersonalizedCfrScores(
+ JSON.stringify(scores)
+ ),
+ scores
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js
new file mode 100644
index 0000000000..a6e0eea3af
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js
@@ -0,0 +1,574 @@
+import {
+ ASRouterTargeting,
+ CachedTargetingGetter,
+ getSortedMessages,
+ QueryCache,
+} from "lib/ASRouterTargeting.jsm";
+import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
+import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+// Note that tests for the ASRouterTargeting environment can be found in
+// test/functional/mochitest/browser_asrouter_targeting.js
+
+describe("#CachedTargetingGetter", () => {
+ const sixHours = 6 * 60 * 60 * 1000;
+ let sandbox;
+ let clock;
+ let frecentStub;
+ let topsitesCache;
+ let globals;
+ let doesAppNeedPinStub;
+ let getAddonsByTypesStub;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ clock = sinon.useFakeTimers();
+ frecentStub = sandbox.stub(
+ global.NewTabUtils.activityStreamProvider,
+ "getTopFrecentSites"
+ );
+ topsitesCache = new CachedTargetingGetter("getTopFrecentSites");
+ globals = new GlobalOverrider();
+ globals.set(
+ "TargetingContext",
+ class {
+ static combineContexts(...args) {
+ return sinon.stub();
+ }
+
+ evalWithDefault(expr) {
+ return sinon.stub();
+ }
+ }
+ );
+ doesAppNeedPinStub = sandbox.stub().resolves();
+ getAddonsByTypesStub = sandbox.stub().resolves();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ clock.restore();
+ globals.restore();
+ });
+
+ it("should cache allow for optional getter argument", async () => {
+ let pinCachedGetter = new CachedTargetingGetter(
+ "doesAppNeedPin",
+ true,
+ undefined,
+ { doesAppNeedPin: doesAppNeedPinStub }
+ );
+ // Need to tick forward because Date.now() is stubbed
+ clock.tick(sixHours);
+
+ await pinCachedGetter.get();
+ await pinCachedGetter.get();
+ await pinCachedGetter.get();
+
+ // Called once; cached request
+ assert.calledOnce(doesAppNeedPinStub);
+
+ // Called with option argument
+ assert.calledWith(doesAppNeedPinStub, true);
+
+ // Expire and call again
+ clock.tick(sixHours);
+ await pinCachedGetter.get();
+
+ // Call goes through
+ assert.calledTwice(doesAppNeedPinStub);
+
+ let themesCachedGetter = new CachedTargetingGetter(
+ "getAddonsByTypes",
+ ["foo"],
+ undefined,
+ { getAddonsByTypes: getAddonsByTypesStub }
+ );
+
+ // Need to tick forward because Date.now() is stubbed
+ clock.tick(sixHours);
+
+ await themesCachedGetter.get();
+ await themesCachedGetter.get();
+ await themesCachedGetter.get();
+
+ // Called once; cached request
+ assert.calledOnce(getAddonsByTypesStub);
+
+ // Called with option argument
+ assert.calledWith(getAddonsByTypesStub, ["foo"]);
+
+ // Expire and call again
+ clock.tick(sixHours);
+ await themesCachedGetter.get();
+
+ // Call goes through
+ assert.calledTwice(getAddonsByTypesStub);
+ });
+
+ it("should only make a request every 6 hours", async () => {
+ frecentStub.resolves();
+ clock.tick(sixHours);
+
+ await topsitesCache.get();
+ await topsitesCache.get();
+
+ assert.calledOnce(
+ global.NewTabUtils.activityStreamProvider.getTopFrecentSites
+ );
+
+ clock.tick(sixHours);
+
+ await topsitesCache.get();
+
+ assert.calledTwice(
+ global.NewTabUtils.activityStreamProvider.getTopFrecentSites
+ );
+ });
+ it("throws when failing getter", async () => {
+ frecentStub.rejects(new Error("fake error"));
+ clock.tick(sixHours);
+
+ // assert.throws expect a function as the first parameter, try/catch is a
+ // workaround
+ let rejected = false;
+ try {
+ await topsitesCache.get();
+ } catch (e) {
+ rejected = true;
+ }
+
+ assert(rejected);
+ });
+ describe("sortMessagesByPriority", () => {
+ it("should sort messages in descending priority order", async () => {
+ const [m1, m2, m3 = { id: "m3" }] =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const checkMessageTargetingStub = sandbox
+ .stub(ASRouterTargeting, "checkMessageTargeting")
+ .resolves(false);
+ sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
+
+ await ASRouterTargeting.findMatchingMessage({
+ messages: [
+ { ...m1, priority: 0 },
+ { ...m2, priority: 1 },
+ { ...m3, priority: 2 },
+ ],
+ trigger: "testing",
+ });
+
+ assert.equal(checkMessageTargetingStub.callCount, 3);
+
+ const [arg_m1] = checkMessageTargetingStub.firstCall.args;
+ assert.equal(arg_m1.id, m3.id);
+
+ const [arg_m2] = checkMessageTargetingStub.secondCall.args;
+ assert.equal(arg_m2.id, m2.id);
+
+ const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
+ assert.equal(arg_m3.id, m1.id);
+ });
+ it("should sort messages with no priority last", async () => {
+ const [m1, m2, m3 = { id: "m3" }] =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const checkMessageTargetingStub = sandbox
+ .stub(ASRouterTargeting, "checkMessageTargeting")
+ .resolves(false);
+ sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
+
+ await ASRouterTargeting.findMatchingMessage({
+ messages: [
+ { ...m1, priority: 0 },
+ { ...m2, priority: undefined },
+ { ...m3, priority: 2 },
+ ],
+ trigger: "testing",
+ });
+
+ assert.equal(checkMessageTargetingStub.callCount, 3);
+
+ const [arg_m1] = checkMessageTargetingStub.firstCall.args;
+ assert.equal(arg_m1.id, m3.id);
+
+ const [arg_m2] = checkMessageTargetingStub.secondCall.args;
+ assert.equal(arg_m2.id, m1.id);
+
+ const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
+ assert.equal(arg_m3.id, m2.id);
+ });
+ it("should keep the order of messages with same priority unchanged", async () => {
+ const [m1, m2, m3 = { id: "m3" }] =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const checkMessageTargetingStub = sandbox
+ .stub(ASRouterTargeting, "checkMessageTargeting")
+ .resolves(false);
+ sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
+
+ await ASRouterTargeting.findMatchingMessage({
+ messages: [
+ { ...m1, priority: 2, targeting: undefined, rank: 1 },
+ { ...m2, priority: undefined, targeting: undefined, rank: 1 },
+ { ...m3, priority: 2, targeting: undefined, rank: 1 },
+ ],
+ trigger: "testing",
+ });
+
+ assert.equal(checkMessageTargetingStub.callCount, 3);
+
+ const [arg_m1] = checkMessageTargetingStub.firstCall.args;
+ assert.equal(arg_m1.id, m1.id);
+
+ const [arg_m2] = checkMessageTargetingStub.secondCall.args;
+ assert.equal(arg_m2.id, m3.id);
+
+ const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
+ assert.equal(arg_m3.id, m2.id);
+ });
+ });
+});
+describe("#isTriggerMatch", () => {
+ let trigger;
+ let message;
+ beforeEach(() => {
+ trigger = { id: "openURL" };
+ message = { id: "openURL" };
+ });
+ it("should return false if trigger and candidate ids are different", () => {
+ trigger.id = "trigger";
+ message.id = "message";
+
+ assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message));
+ assert.isTrue(
+ ASRouterTargeting.isTriggerMatch({ id: "foo" }, { id: "foo" })
+ );
+ });
+ it("should return true if the message we check doesn't have trigger params or patterns", () => {
+ // No params or patterns defined
+ assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message));
+ });
+ it("should return false if the trigger does not have params defined", () => {
+ message.params = {};
+
+ // trigger.param is undefined
+ assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message));
+ });
+ it("should return true if message params includes trigger host", () => {
+ message.params = ["mozilla.org"];
+ trigger.param = { host: "mozilla.org" };
+
+ assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message));
+ });
+ it("should return true if message params includes trigger param.type", () => {
+ message.params = ["ContentBlockingMilestone"];
+ trigger.param = { type: "ContentBlockingMilestone" };
+
+ assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message)));
+ });
+ it("should return true if message params match trigger mask", () => {
+ // STATE_BLOCKED_FINGERPRINTING_CONTENT
+ message.params = [0x00000040];
+ trigger.param = { type: 538091584 };
+
+ assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message)));
+ });
+});
+describe("#CacheListAttachedOAuthClients", () => {
+ const fourHours = 4 * 60 * 60 * 1000;
+ let sandbox;
+ let clock;
+ let fakeFxAccount;
+ let authClientsCache;
+ let globals;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ clock = sinon.useFakeTimers();
+ fakeFxAccount = {
+ listAttachedOAuthClients: () => {},
+ };
+ globals.set("fxAccounts", fakeFxAccount);
+ authClientsCache = QueryCache.queries.ListAttachedOAuthClients;
+ sandbox
+ .stub(global.fxAccounts, "listAttachedOAuthClients")
+ .returns(Promise.resolve({}));
+ });
+
+ afterEach(() => {
+ authClientsCache.expire();
+ sandbox.restore();
+ clock.restore();
+ });
+
+ it("should only make additional request every 4 hours", async () => {
+ clock.tick(fourHours);
+
+ await authClientsCache.get();
+ assert.calledOnce(global.fxAccounts.listAttachedOAuthClients);
+
+ clock.tick(fourHours);
+ await authClientsCache.get();
+ assert.calledTwice(global.fxAccounts.listAttachedOAuthClients);
+ });
+
+ it("should not make additional request before 4 hours", async () => {
+ clock.tick(fourHours);
+
+ await authClientsCache.get();
+ assert.calledOnce(global.fxAccounts.listAttachedOAuthClients);
+
+ await authClientsCache.get();
+ assert.calledOnce(global.fxAccounts.listAttachedOAuthClients);
+ });
+});
+describe("ASRouterTargeting", () => {
+ let evalStub;
+ let sandbox;
+ let clock;
+ let globals;
+ let fakeTargetingContext;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ sandbox.replace(ASRouterTargeting, "Environment", {});
+ clock = sinon.useFakeTimers();
+ fakeTargetingContext = {
+ combineContexts: sandbox.stub(),
+ evalWithDefault: sandbox.stub().resolves(),
+ setTelemetrySource: sandbox.stub(),
+ };
+ globals = new GlobalOverrider();
+ globals.set(
+ "TargetingContext",
+ class {
+ static combineContexts(...args) {
+ return fakeTargetingContext.combineContexts.apply(sandbox, args);
+ }
+
+ setTelemetrySource(id) {
+ fakeTargetingContext.setTelemetrySource(id);
+ }
+
+ evalWithDefault(expr) {
+ return fakeTargetingContext.evalWithDefault(expr);
+ }
+ }
+ );
+ evalStub = fakeTargetingContext.evalWithDefault;
+ });
+ afterEach(() => {
+ clock.restore();
+ sandbox.restore();
+ globals.restore();
+ });
+ it("should provide message.id as source", async () => {
+ await ASRouterTargeting.checkMessageTargeting(
+ {
+ id: "message",
+ targeting: "true",
+ },
+ fakeTargetingContext,
+ sandbox.stub(),
+ false
+ );
+ assert.calledOnce(fakeTargetingContext.evalWithDefault);
+ assert.calledWithExactly(fakeTargetingContext.evalWithDefault, "true");
+ assert.calledWithExactly(
+ fakeTargetingContext.setTelemetrySource,
+ "message"
+ );
+ });
+ it("should cache evaluation result", async () => {
+ evalStub.resolves(true);
+ let targetingContext = new global.TargetingContext();
+
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl1" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl2" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl1" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+
+ assert.calledTwice(evalStub);
+ });
+ it("should not cache evaluation result", async () => {
+ evalStub.resolves(true);
+ let targetingContext = new global.TargetingContext();
+
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ false
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ false
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ false
+ );
+
+ assert.calledThrice(evalStub);
+ });
+ it("should expire cache entries", async () => {
+ evalStub.resolves(true);
+ let targetingContext = new global.TargetingContext();
+
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+ clock.tick(5 * 60 * 1000 + 1);
+ await ASRouterTargeting.checkMessageTargeting(
+ { targeting: "jexl" },
+ targetingContext,
+ sandbox.stub(),
+ true
+ );
+
+ assert.calledTwice(evalStub);
+ });
+
+ describe("#findMatchingMessage", () => {
+ let matchStub;
+ let messages = [
+ { id: "FOO", targeting: "match" },
+ { id: "BAR", targeting: "match" },
+ { id: "BAZ" },
+ ];
+ beforeEach(() => {
+ matchStub = sandbox
+ .stub(ASRouterTargeting, "_isMessageMatch")
+ .callsFake(message => message.targeting === "match");
+ });
+ it("should return an array of matches if returnAll is true", async () => {
+ assert.deepEqual(
+ await ASRouterTargeting.findMatchingMessage({
+ messages,
+ returnAll: true,
+ }),
+ [
+ { id: "FOO", targeting: "match" },
+ { id: "BAR", targeting: "match" },
+ ]
+ );
+ });
+ it("should return an empty array if no matches were found and returnAll is true", async () => {
+ matchStub.returns(false);
+ assert.deepEqual(
+ await ASRouterTargeting.findMatchingMessage({
+ messages,
+ returnAll: true,
+ }),
+ []
+ );
+ });
+ it("should return the first match if returnAll is false", async () => {
+ assert.deepEqual(
+ await ASRouterTargeting.findMatchingMessage({
+ messages,
+ }),
+ messages[0]
+ );
+ });
+ it("should return null if if no matches were found and returnAll is false", async () => {
+ matchStub.returns(false);
+ assert.deepEqual(
+ await ASRouterTargeting.findMatchingMessage({
+ messages,
+ }),
+ null
+ );
+ });
+ });
+});
+
+/**
+ * Messages should be sorted in the following order:
+ * 1. Rank
+ * 2. Priority
+ * 3. If the message has targeting
+ * 4. Order or randomization, depending on input
+ */
+describe("getSortedMessages", () => {
+ let globals = new GlobalOverrider();
+ let sandbox;
+ beforeEach(() => {
+ globals.set({ ASRouterPreferences });
+ sandbox = sinon.createSandbox();
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ /**
+ * assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages,
+ * returns the items in the expected order.
+ *
+ * @param {Message[]} expectedOrderArray - The array of messages in its expected order
+ * @param {{}} options - The options param for getSortedMessages
+ * @returns
+ */
+ function assertSortsCorrectly(expectedOrderArray, options) {
+ const input = [...expectedOrderArray].reverse();
+ const result = getSortedMessages(input, options);
+ const indexes = result.map(message => expectedOrderArray.indexOf(message));
+ return assert.equal(
+ indexes.join(","),
+ [...expectedOrderArray.keys()].join(","),
+ "Messsages are out of order"
+ );
+ }
+
+ it("should sort messages by priority, then by targeting", () => {
+ assertSortsCorrectly([
+ { priority: 100, targeting: "isFoo" },
+ { priority: 100 },
+ { priority: 99 },
+ { priority: 1, targeting: "isFoo" },
+ { priority: 1 },
+ {},
+ ]);
+ });
+ it("should sort messages by priority, then targeting, then order if ordered param is true", () => {
+ assertSortsCorrectly(
+ [
+ { priority: 100, order: 4 },
+ { priority: 100, order: 5 },
+ { priority: 1, order: 3, targeting: "isFoo" },
+ { priority: 1, order: 0 },
+ { priority: 1, order: 1 },
+ { priority: 1, order: 2 },
+ { order: 0 },
+ ],
+ { ordered: true }
+ );
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js
new file mode 100644
index 0000000000..52a7785e05
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js
@@ -0,0 +1,778 @@
+import { ASRouterTriggerListeners } from "lib/ASRouterTriggerListeners.jsm";
+import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("ASRouterTriggerListeners", () => {
+ let sandbox;
+ let globals;
+ let existingWindow;
+ let isWindowPrivateStub;
+ const triggerHandler = () => {};
+ const openURLListener = ASRouterTriggerListeners.get("openURL");
+ const frequentVisitsListener = ASRouterTriggerListeners.get("frequentVisits");
+ const captivePortalLoginListener =
+ ASRouterTriggerListeners.get("captivePortalLogin");
+ const bookmarkedURLListener =
+ ASRouterTriggerListeners.get("openBookmarkedURL");
+ const openArticleURLListener = ASRouterTriggerListeners.get("openArticleURL");
+ const nthTabClosedListener = ASRouterTriggerListeners.get("nthTabClosed");
+ const idleListener = ASRouterTriggerListeners.get("activityAfterIdle");
+ const formAutofillListener = ASRouterTriggerListeners.get("formAutofill");
+ const cookieBannerDetectedListener = ASRouterTriggerListeners.get(
+ "cookieBannerDetected"
+ );
+ const hosts = ["www.mozilla.com", "www.mozilla.org"];
+
+ const regionFake = {
+ _home: "cn",
+ _current: "cn",
+ get home() {
+ return this._home;
+ },
+ get current() {
+ return this._current;
+ },
+ };
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ existingWindow = {
+ gBrowser: {
+ addTabsProgressListener: sandbox.stub(),
+ removeTabsProgressListener: sandbox.stub(),
+ currentURI: { host: "" },
+ },
+ addEventListener: sinon.stub(),
+ removeEventListener: sinon.stub(),
+ };
+ sandbox.spy(openURLListener, "init");
+ sandbox.spy(openURLListener, "uninit");
+ isWindowPrivateStub = sandbox.stub();
+ // Assume no window is private so that we execute the action
+ isWindowPrivateStub.returns(false);
+ globals.set("PrivateBrowsingUtils", {
+ isWindowPrivate: isWindowPrivateStub,
+ });
+ const ewUninit = new Map();
+ globals.set("EveryWindow", {
+ registerCallback: (id, init, uninit) => {
+ init(existingWindow);
+ ewUninit.set(id, uninit);
+ },
+ unregisterCallback: id => {
+ ewUninit.get(id)(existingWindow);
+ },
+ });
+ globals.set("Region", regionFake);
+ globals.set("ASRouterPreferences", ASRouterPreferences);
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("openBookmarkedURL", () => {
+ let observerStub;
+ describe("#init", () => {
+ beforeEach(() => {
+ observerStub = sandbox.stub(global.Services.obs, "addObserver");
+ sandbox
+ .stub(global.Services.wm, "getMostRecentBrowserWindow")
+ .returns({ gBrowser: { selectedBrowser: {} } });
+ });
+ afterEach(() => {
+ bookmarkedURLListener.uninit();
+ });
+ it("should set hosts to the recentBookmarks", async () => {
+ await bookmarkedURLListener.init(sandbox.stub());
+
+ assert.calledOnce(observerStub);
+ assert.calledWithExactly(
+ observerStub,
+ bookmarkedURLListener,
+ "bookmark-icon-updated"
+ );
+ });
+ it("should provide id to triggerHandler", async () => {
+ const newTriggerHandler = sinon.stub();
+ const subject = {};
+ await bookmarkedURLListener.init(newTriggerHandler);
+
+ bookmarkedURLListener.observe(
+ subject,
+ "bookmark-icon-updated",
+ "starred"
+ );
+
+ assert.calledOnce(newTriggerHandler);
+ assert.calledWithExactly(newTriggerHandler, subject, {
+ id: bookmarkedURLListener.id,
+ });
+ });
+ });
+ });
+
+ describe("captivePortal", () => {
+ describe("observe", () => {
+ it("should not call the trigger handler if _shouldShowCaptivePortalVPNPromo returns false", () => {
+ sandbox
+ .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo")
+ .returns(false);
+ captivePortalLoginListener._triggerHandler = sandbox.spy();
+
+ captivePortalLoginListener.observe(
+ null,
+ "captive-portal-login-success"
+ );
+
+ assert.notCalled(captivePortalLoginListener._triggerHandler);
+ });
+
+ it("should call the trigger handler if _shouldShowCaptivePortalVPNPromo returns true", () => {
+ sandbox
+ .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo")
+ .returns(true);
+ sandbox.stub(Services.wm, "getMostRecentBrowserWindow").returns({
+ gBrowser: {
+ selectedBrowser: true,
+ },
+ });
+ captivePortalLoginListener._triggerHandler = sandbox.spy();
+
+ captivePortalLoginListener.observe(
+ null,
+ "captive-portal-login-success"
+ );
+
+ assert.calledOnce(captivePortalLoginListener._triggerHandler);
+ });
+ });
+ });
+
+ describe("openArticleURL", () => {
+ describe("#init", () => {
+ beforeEach(() => {
+ globals.set(
+ "MatchPatternSet",
+ sandbox.stub().callsFake(patterns => ({
+ patterns,
+ matches: url => patterns.has(url),
+ }))
+ );
+ sandbox.stub(global.AboutReaderParent, "addMessageListener");
+ sandbox.stub(global.AboutReaderParent, "removeMessageListener");
+ });
+ afterEach(() => {
+ openArticleURLListener.uninit();
+ });
+ it("setup an event listener on init", () => {
+ openArticleURLListener.init(sandbox.stub(), hosts, hosts);
+
+ assert.calledOnce(global.AboutReaderParent.addMessageListener);
+ assert.calledWithExactly(
+ global.AboutReaderParent.addMessageListener,
+ openArticleURLListener.readerModeEvent,
+ sinon.match.object
+ );
+ });
+ it("should call triggerHandler correctly for matches [host match]", () => {
+ const stub = sandbox.stub();
+ const target = { currentURI: { host: hosts[0], spec: hosts[1] } };
+ openArticleURLListener.init(stub, hosts, hosts);
+
+ const [, { receiveMessage }] =
+ global.AboutReaderParent.addMessageListener.firstCall.args;
+ receiveMessage({ data: { isArticle: true }, target });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, target, {
+ id: openArticleURLListener.id,
+ param: { host: hosts[0], url: hosts[1] },
+ });
+ });
+ it("should call triggerHandler correctly for matches [pattern match]", () => {
+ const stub = sandbox.stub();
+ const target = { currentURI: { host: null, spec: hosts[1] } };
+ openArticleURLListener.init(stub, hosts, hosts);
+
+ const [, { receiveMessage }] =
+ global.AboutReaderParent.addMessageListener.firstCall.args;
+ receiveMessage({ data: { isArticle: true }, target });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, target, {
+ id: openArticleURLListener.id,
+ param: { host: null, url: hosts[1] },
+ });
+ });
+ it("should remove the message listener", () => {
+ openArticleURLListener.init(sandbox.stub(), hosts, hosts);
+ openArticleURLListener.uninit();
+
+ assert.calledOnce(global.AboutReaderParent.removeMessageListener);
+ });
+ });
+ });
+
+ describe("frequentVisits", () => {
+ let _triggerHandler;
+ beforeEach(() => {
+ _triggerHandler = sandbox.stub();
+ sandbox.useFakeTimers();
+ frequentVisitsListener.init(_triggerHandler, hosts);
+ });
+ afterEach(() => {
+ sandbox.clock.restore();
+ frequentVisitsListener.uninit();
+ });
+ it("should be initialized", () => {
+ assert.isTrue(frequentVisitsListener._initialized);
+ });
+ it("should listen for TabSelect events", () => {
+ assert.calledOnce(existingWindow.addEventListener);
+ assert.calledWith(
+ existingWindow.addEventListener,
+ "TabSelect",
+ frequentVisitsListener.onTabSwitch
+ );
+ });
+ it("should call _triggerHandler if the visit is valid (is recoreded)", () => {
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+
+ assert.calledOnce(_triggerHandler);
+ });
+ it("should call _triggerHandler only once", () => {
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+
+ assert.calledOnce(_triggerHandler);
+ });
+ it("should call _triggerHandler again after 15 minutes", () => {
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+ sandbox.clock.tick(15 * 60 * 1000 + 1);
+ frequentVisitsListener.triggerHandler({}, "www.mozilla.com");
+
+ assert.calledTwice(_triggerHandler);
+ });
+ it("should call triggerHandler on valid hosts", () => {
+ const stub = sandbox.stub(frequentVisitsListener, "triggerHandler");
+ existingWindow.gBrowser.currentURI.host = hosts[0]; // eslint-disable-line prefer-destructuring
+
+ frequentVisitsListener.onTabSwitch({
+ target: { ownerGlobal: existingWindow },
+ });
+
+ assert.calledOnce(stub);
+ });
+ it("should not call triggerHandler on invalid hosts", () => {
+ const stub = sandbox.stub(frequentVisitsListener, "triggerHandler");
+ existingWindow.gBrowser.currentURI.host = "foo.com";
+
+ frequentVisitsListener.onTabSwitch({
+ target: { ownerGlobal: existingWindow },
+ });
+
+ assert.notCalled(stub);
+ });
+ describe("MatchPattern", () => {
+ beforeEach(() => {
+ globals.set(
+ "MatchPatternSet",
+ sandbox.stub().callsFake(patterns => ({ patterns: patterns || [] }))
+ );
+ });
+ afterEach(() => {
+ frequentVisitsListener.uninit();
+ });
+ it("should create a matchPatternSet", () => {
+ frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]);
+
+ assert.calledOnce(window.MatchPatternSet);
+ assert.calledWithExactly(
+ window.MatchPatternSet,
+ new Set(["pattern"]),
+ undefined
+ );
+ });
+ it("should allow to add multiple patterns and dedupe", () => {
+ frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]);
+ frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]);
+
+ assert.calledTwice(window.MatchPatternSet);
+ assert.calledWithExactly(
+ window.MatchPatternSet,
+ new Set(["pattern", "foo"]),
+ undefined
+ );
+ });
+ it("should handle bad arguments to MatchPatternSet", () => {
+ const badArgs = ["www.example.com"];
+ window.MatchPatternSet.withArgs(new Set(badArgs)).throws();
+ frequentVisitsListener.init(_triggerHandler, hosts, badArgs);
+
+ // Fails with an empty MatchPatternSet
+ assert.property(frequentVisitsListener._matchPatternSet, "patterns");
+
+ // Second try is succesful
+ frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]);
+
+ assert.property(frequentVisitsListener._matchPatternSet, "patterns");
+ assert.isTrue(
+ frequentVisitsListener._matchPatternSet.patterns.has("foo")
+ );
+ });
+ });
+ });
+
+ describe("nthTabClosed", () => {
+ describe("#init", () => {
+ beforeEach(() => {
+ nthTabClosedListener.init(triggerHandler);
+ });
+ afterEach(() => {
+ nthTabClosedListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler", () => {
+ assert.ok(nthTabClosedListener._initialized);
+ assert.equal(nthTabClosedListener._triggerHandler, triggerHandler);
+ });
+
+ it("if already initialised, it should only update the trigger handler", () => {
+ const newTriggerHandler = () => {};
+ nthTabClosedListener.init(newTriggerHandler);
+ assert.ok(nthTabClosedListener._initialized);
+ assert.equal(nthTabClosedListener._triggerHandler, newTriggerHandler);
+ });
+
+ it("should add an event listeners to all existing browser windows", () => {
+ assert.calledOnce(existingWindow.addEventListener);
+ assert.calledWith(existingWindow.addEventListener, "TabClose");
+ });
+ });
+
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ nthTabClosedListener.init(triggerHandler);
+ nthTabClosedListener.uninit();
+ });
+ it("should set ._initialized to false and clear the triggerHandler, closed tabs count", () => {
+ assert.notOk(nthTabClosedListener._initialized);
+ assert.equal(nthTabClosedListener._triggerHandler, null);
+ assert.equal(nthTabClosedListener._closedTabs, 0);
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ nthTabClosedListener.uninit();
+ assert.notOk(nthTabClosedListener._initialized);
+ });
+
+ it("should remove event listeners from all existing browser windows", () => {
+ assert.calledOnce(existingWindow.removeEventListener);
+ });
+ });
+ });
+
+ describe("activityAfterIdle", () => {
+ let addObsStub;
+ let removeObsStub;
+ describe("#init", () => {
+ beforeEach(() => {
+ addObsStub = sandbox.stub(global.Services.obs, "addObserver");
+ sandbox
+ .stub(global.Services.wm, "getEnumerator")
+ .returns([{ closed: false, document: { hidden: false } }]);
+ idleListener.init(triggerHandler);
+ });
+ afterEach(() => {
+ idleListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler", () => {
+ assert.ok(idleListener._initialized);
+ assert.equal(idleListener._triggerHandler, triggerHandler);
+ });
+
+ it("if already initialised, it should only update the trigger handler", () => {
+ const newTriggerHandler = () => {};
+ idleListener.init(newTriggerHandler);
+ assert.ok(idleListener._initialized);
+ assert.equal(idleListener._triggerHandler, newTriggerHandler);
+ });
+
+ it("should add observers for idle and activity", () => {
+ assert.called(addObsStub);
+ });
+
+ it("should add event listeners to all existing browser windows", () => {
+ assert.called(existingWindow.addEventListener);
+ });
+ });
+
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ removeObsStub = sandbox.stub(global.Services.obs, "removeObserver");
+ sandbox.stub(global.Services.wm, "getEnumerator").returns([]);
+ idleListener.init(triggerHandler);
+ idleListener.uninit();
+ });
+ it("should set ._initialized to false and clear the triggerHandler and timestamps", () => {
+ assert.notOk(idleListener._initialized);
+ assert.equal(idleListener._triggerHandler, null);
+ assert.equal(idleListener._quietSince, null);
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ idleListener.uninit();
+ assert.notOk(idleListener._initialized);
+ });
+
+ it("should remove observers for idle and activity", () => {
+ assert.called(removeObsStub);
+ });
+
+ it("should remove event listeners from all existing browser windows", () => {
+ assert.called(existingWindow.removeEventListener);
+ });
+ });
+ });
+
+ describe("formAutofill", () => {
+ let addObsStub;
+ let removeObsStub;
+ describe("#init", () => {
+ beforeEach(() => {
+ addObsStub = sandbox.stub(global.Services.obs, "addObserver");
+ formAutofillListener.init(triggerHandler);
+ });
+ afterEach(() => {
+ formAutofillListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler", () => {
+ assert.ok(formAutofillListener._initialized);
+ assert.equal(formAutofillListener._triggerHandler, triggerHandler);
+ });
+
+ it("if already initialised, it should only update the trigger handler", () => {
+ const newTriggerHandler = () => {};
+ formAutofillListener.init(newTriggerHandler);
+ assert.ok(formAutofillListener._initialized);
+ assert.equal(formAutofillListener._triggerHandler, newTriggerHandler);
+ });
+
+ it(`should add observer for ${formAutofillListener._topic}`, () => {
+ assert.called(addObsStub);
+ });
+ });
+
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ removeObsStub = sandbox.stub(global.Services.obs, "removeObserver");
+ formAutofillListener.init(triggerHandler);
+ formAutofillListener.uninit();
+ });
+
+ it("should set ._initialized to false and clear the triggerHandler", () => {
+ assert.notOk(formAutofillListener._initialized);
+ assert.equal(formAutofillListener._triggerHandler, null);
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ formAutofillListener.uninit();
+ assert.notOk(formAutofillListener._initialized);
+ });
+
+ it(`should remove observers for ${formAutofillListener._topic}`, () => {
+ assert.called(removeObsStub);
+ });
+ });
+ });
+
+ describe("openURL listener", () => {
+ it("should exist and initially be uninitialised", () => {
+ assert.ok(openURLListener);
+ assert.notOk(openURLListener._initialized);
+ });
+
+ describe("#init", () => {
+ beforeEach(() => {
+ openURLListener.init(triggerHandler, hosts);
+ });
+ afterEach(() => {
+ openURLListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler and hosts", () => {
+ assert.ok(openURLListener._initialized);
+ assert.deepEqual(openURLListener._hosts, new Set(hosts));
+ assert.equal(openURLListener._triggerHandler, triggerHandler);
+ });
+
+ it("should add tab progress listeners to all existing browser windows", () => {
+ assert.calledOnce(existingWindow.gBrowser.addTabsProgressListener);
+ assert.calledWithExactly(
+ existingWindow.gBrowser.addTabsProgressListener,
+ openURLListener
+ );
+ });
+
+ it("if already initialised, should only update the trigger handler and add the new hosts", () => {
+ const newHosts = ["www.example.com"];
+ const newTriggerHandler = () => {};
+ existingWindow.gBrowser.addTabsProgressListener.reset();
+
+ openURLListener.init(newTriggerHandler, newHosts);
+ assert.ok(openURLListener._initialized);
+ assert.deepEqual(
+ openURLListener._hosts,
+ new Set([...hosts, ...newHosts])
+ );
+ assert.equal(openURLListener._triggerHandler, newTriggerHandler);
+ assert.notCalled(existingWindow.gBrowser.addTabsProgressListener);
+ });
+ });
+
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ openURLListener.init(triggerHandler, hosts);
+ openURLListener.uninit();
+ });
+
+ it("should set ._initialized to false and clear the triggerHandler and hosts", () => {
+ assert.notOk(openURLListener._initialized);
+ assert.equal(openURLListener._hosts, null);
+ assert.equal(openURLListener._triggerHandler, null);
+ });
+
+ it("should remove tab progress listeners from all existing browser windows", () => {
+ assert.calledOnce(existingWindow.gBrowser.removeTabsProgressListener);
+ assert.calledWithExactly(
+ existingWindow.gBrowser.removeTabsProgressListener,
+ openURLListener
+ );
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ existingWindow.gBrowser.removeTabsProgressListener.reset();
+
+ openURLListener.uninit();
+ assert.notOk(openURLListener._initialized);
+ assert.notCalled(existingWindow.gBrowser.removeTabsProgressListener);
+ });
+ });
+
+ describe("#onLocationChange", () => {
+ afterEach(() => {
+ openURLListener.uninit();
+ frequentVisitsListener.uninit();
+ });
+
+ it("should call the ._triggerHandler with the right arguments", () => {
+ const newTriggerHandler = sinon.stub();
+ openURLListener.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const location = "www.mozilla.org";
+ openURLListener.onLocationChange(browser, webProgress, undefined, {
+ host: location,
+ spec: location,
+ });
+ assert.calledOnce(newTriggerHandler);
+ assert.calledWithExactly(newTriggerHandler, browser, {
+ id: "openURL",
+ param: { host: "www.mozilla.org", url: "www.mozilla.org" },
+ context: { visitsCount: 1 },
+ });
+ });
+ it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => {
+ for (let trigger of [openURLListener, frequentVisitsListener]) {
+ const newTriggerHandler = sinon.stub();
+ trigger.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
+ }),
+ };
+ trigger.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledOnce(aRequest.QueryInterface);
+ assert.calledOnce(newTriggerHandler);
+ }
+ });
+ it("should call triggerHandler with the right arguments (redirect)", () => {
+ const newTriggerHandler = sinon.stub();
+ openURLListener.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
+ }),
+ };
+ openURLListener.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledWithExactly(newTriggerHandler, browser, {
+ id: "openURL",
+ param: { host: "www.mozilla.org", url: "www.mozilla.org" },
+ context: { visitsCount: 1 },
+ });
+ });
+ it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => {
+ for (let trigger of [openURLListener, frequentVisitsListener]) {
+ const newTriggerHandler = sinon.stub();
+ trigger.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
+ }),
+ };
+ trigger.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledOnce(aRequest.QueryInterface);
+ assert.calledOnce(newTriggerHandler);
+ }
+ });
+ it("should call triggerHandler with the right arguments (redirect)", () => {
+ const newTriggerHandler = sinon.stub();
+ openURLListener.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" },
+ }),
+ };
+ openURLListener.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledWithExactly(newTriggerHandler, browser, {
+ id: "openURL",
+ param: { host: "www.mozilla.org", url: "www.mozilla.org" },
+ context: { visitsCount: 1 },
+ });
+ });
+ it("should fail for subdomains (not redirect)", () => {
+ const newTriggerHandler = sinon.stub();
+ openURLListener.init(newTriggerHandler, hosts);
+
+ const browser = {};
+ const webProgress = { isTopLevel: true };
+ const aLocationURI = {
+ host: "subdomain.mozilla.org",
+ spec: "subdomain.mozilla.org",
+ };
+ const aRequest = {
+ QueryInterface: sandbox.stub().returns({
+ originalURI: {
+ spec: "subdomain.mozilla.org",
+ host: "subdomain.mozilla.org",
+ },
+ }),
+ };
+ openURLListener.onLocationChange(
+ browser,
+ webProgress,
+ aRequest,
+ aLocationURI
+ );
+ assert.calledOnce(aRequest.QueryInterface);
+ assert.notCalled(newTriggerHandler);
+ });
+ });
+ });
+
+ describe("cookieBannerDetected", () => {
+ describe("#init", () => {
+ beforeEach(() => {
+ cookieBannerDetectedListener.init(triggerHandler);
+ });
+ afterEach(() => {
+ cookieBannerDetectedListener.uninit();
+ });
+
+ it("should set ._initialized to true and save the triggerHandler", () => {
+ assert.ok(cookieBannerDetectedListener._initialized);
+ assert.equal(
+ cookieBannerDetectedListener._triggerHandler,
+ triggerHandler
+ );
+ });
+
+ it("if already initialised, it should only update the trigger handler", () => {
+ const newTriggerHandler = () => {};
+ cookieBannerDetectedListener.init(newTriggerHandler);
+ assert.ok(cookieBannerDetectedListener._initialized);
+ assert.equal(
+ cookieBannerDetectedListener._triggerHandler,
+ newTriggerHandler
+ );
+ });
+
+ it("should add an event listeners to all existing browser windows", () => {
+ assert.calledOnce(existingWindow.addEventListener);
+ });
+ });
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ cookieBannerDetectedListener.init(triggerHandler);
+ cookieBannerDetectedListener.uninit();
+ });
+ it("should set ._initialized to false and clear the triggerHandler and timestamps", () => {
+ assert.notOk(cookieBannerDetectedListener._initialized);
+ assert.equal(cookieBannerDetectedListener._triggerHandler, null);
+ });
+
+ it("should do nothing if already uninitialised", () => {
+ cookieBannerDetectedListener.uninit();
+ assert.notOk(cookieBannerDetectedListener._initialized);
+ });
+
+ it("should remove event listeners from all existing browser windows", () => {
+ assert.called(existingWindow.removeEventListener);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js b/browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js
new file mode 100644
index 0000000000..a5748d59ce
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js
@@ -0,0 +1,32 @@
+import { CFRMessageProvider } from "lib/CFRMessageProvider.sys.mjs";
+
+const REGULAR_IDS = [
+ "FACEBOOK_CONTAINER",
+ "GOOGLE_TRANSLATE",
+ "YOUTUBE_ENHANCE",
+ // These are excluded for now.
+ // "WIKIPEDIA_CONTEXT_MENU_SEARCH",
+ // "REDDIT_ENHANCEMENT",
+];
+
+describe("CFRMessageProvider", () => {
+ let messages;
+ beforeEach(async () => {
+ messages = await CFRMessageProvider.getMessages();
+ });
+ it("should have a total of 11 messages", () => {
+ assert.lengthOf(messages, 11);
+ });
+ it("should have one message each for the three regular addons", () => {
+ for (const id of REGULAR_IDS) {
+ const cohort3 = messages.find(msg => msg.id === `${id}_3`);
+ assert.ok(cohort3, `contains three day cohort for ${id}`);
+ assert.deepEqual(
+ cohort3.frequency,
+ { lifetime: 3 },
+ "three day cohort has the right frequency cap"
+ );
+ assert.notInclude(cohort3.targeting, `providerCohorts.cfr`);
+ }
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js b/browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js
new file mode 100644
index 0000000000..744b9f148c
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js
@@ -0,0 +1,1252 @@
+/* eslint max-nested-callbacks: ["error", 100] */
+
+import { CFRPageActions, PageAction } from "lib/CFRPageActions.jsm";
+import { FAKE_RECOMMENDATION } from "./constants";
+import { GlobalOverrider } from "test/unit/utils";
+import { CFRMessageProvider } from "lib/CFRMessageProvider.sys.mjs";
+
+describe("CFRPageActions", () => {
+ let sandbox;
+ let clock;
+ let fakeRecommendation;
+ let fakeHost;
+ let fakeBrowser;
+ let dispatchStub;
+ let globals;
+ let containerElem;
+ let elements;
+ let announceStub;
+ let fakeRemoteL10n;
+
+ const elementIDs = [
+ "urlbar",
+ "urlbar-input",
+ "contextual-feature-recommendation",
+ "cfr-button",
+ "cfr-label",
+ "contextual-feature-recommendation-notification",
+ "cfr-notification-header-label",
+ "cfr-notification-header-link",
+ "cfr-notification-header-image",
+ "cfr-notification-author",
+ "cfr-notification-footer",
+ "cfr-notification-footer-text",
+ "cfr-notification-footer-filled-stars",
+ "cfr-notification-footer-empty-stars",
+ "cfr-notification-footer-users",
+ "cfr-notification-footer-spacer",
+ "cfr-notification-footer-learn-more-link",
+ ];
+ const elementClassNames = ["popup-notification-body-container"];
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ clock = sandbox.useFakeTimers();
+
+ announceStub = sandbox.stub();
+ const A11yUtils = { announce: announceStub };
+ fakeRecommendation = { ...FAKE_RECOMMENDATION };
+ fakeHost = "mozilla.org";
+ fakeBrowser = {
+ documentURI: {
+ scheme: "https",
+ host: fakeHost,
+ },
+ ownerGlobal: window,
+ };
+ dispatchStub = sandbox.stub();
+
+ fakeRemoteL10n = {
+ l10n: {},
+ reloadL10n: sandbox.stub(),
+ createElement: sandbox.stub().returns(document.createElement("div")),
+ };
+
+ const gURLBar = document.createElement("div");
+ gURLBar.textbox = document.createElement("div");
+
+ globals = new GlobalOverrider();
+ globals.set({
+ RemoteL10n: fakeRemoteL10n,
+ promiseDocumentFlushed: sandbox
+ .stub()
+ .callsFake(fn => Promise.resolve(fn())),
+ PopupNotifications: {
+ show: sandbox.stub(),
+ remove: sandbox.stub(),
+ },
+ PrivateBrowsingUtils: { isWindowPrivate: sandbox.stub().returns(false) },
+ gBrowser: { selectedBrowser: fakeBrowser },
+ A11yUtils,
+ gURLBar,
+ });
+ document.createXULElement = document.createElement;
+
+ elements = {};
+ const [body] = document.getElementsByTagName("body");
+ containerElem = document.createElement("div");
+ body.appendChild(containerElem);
+ for (const id of elementIDs) {
+ const elem = document.createElement("div");
+ elem.setAttribute("id", id);
+ containerElem.appendChild(elem);
+ elements[id] = elem;
+ }
+ for (const className of elementClassNames) {
+ const elem = document.createElement("div");
+ elem.setAttribute("class", className);
+ containerElem.appendChild(elem);
+ elements[className] = elem;
+ }
+ });
+
+ afterEach(() => {
+ CFRPageActions.clearRecommendations();
+ containerElem.remove();
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("PageAction", () => {
+ let pageAction;
+
+ beforeEach(() => {
+ pageAction = new PageAction(window, dispatchStub);
+ });
+
+ describe("#addImpression", () => {
+ it("should call _sendTelemetry with the impression payload", () => {
+ const recommendation = {
+ id: "foo",
+ content: { bucket_id: "bar" },
+ };
+ sandbox.spy(pageAction, "_sendTelemetry");
+
+ pageAction.addImpression(recommendation);
+
+ assert.calledWith(pageAction._sendTelemetry, {
+ message_id: "foo",
+ bucket_id: "bar",
+ event: "IMPRESSION",
+ });
+ });
+ });
+
+ describe("#showAddressBarNotifier", () => {
+ it("should un-hideAddressBarNotifier the element and set the right label value", async () => {
+ await pageAction.showAddressBarNotifier(fakeRecommendation);
+ assert.isFalse(pageAction.container.hidden);
+ assert.equal(
+ pageAction.label.value,
+ fakeRecommendation.content.notification_text
+ );
+ });
+ it("should wait for the document layout to flush", async () => {
+ sandbox.spy(pageAction.label, "getClientRects");
+ await pageAction.showAddressBarNotifier(fakeRecommendation);
+ assert.calledOnce(global.promiseDocumentFlushed);
+ assert.callOrder(
+ global.promiseDocumentFlushed,
+ pageAction.label.getClientRects
+ );
+ });
+ it("should set the CSS variable --cfr-label-width correctly", async () => {
+ await pageAction.showAddressBarNotifier(fakeRecommendation);
+ const expectedWidth = pageAction.label.getClientRects()[0].width;
+ assert.equal(
+ pageAction.urlbarinput.style.getPropertyValue("--cfr-label-width"),
+ `${expectedWidth}px`
+ );
+ });
+ it("should cause an expansion, and dispatch an impression if `expand` is true", async () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ sandbox.spy(pageAction, "_expand");
+ sandbox.spy(pageAction, "_dispatchImpression");
+
+ await pageAction.showAddressBarNotifier(fakeRecommendation);
+ assert.notCalled(pageAction._dispatchImpression);
+ clock.tick(1001);
+ assert.notEqual(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "expanded"
+ );
+
+ await pageAction.showAddressBarNotifier(fakeRecommendation, true);
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ clock.tick(1001);
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "expanded"
+ );
+ assert.calledOnce(pageAction._dispatchImpression);
+ assert.calledWith(pageAction._dispatchImpression, fakeRecommendation);
+ });
+ it("should send telemetry if `expand` is true and the id and bucket_id are provided", async () => {
+ await pageAction.showAddressBarNotifier(fakeRecommendation, true);
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "IMPRESSION",
+ },
+ });
+ });
+ });
+
+ describe("#hideAddressBarNotifier", () => {
+ it("should hideAddressBarNotifier the container, cancel any state changes, and remove the state attribute", () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ pageAction.hideAddressBarNotifier();
+ assert.isTrue(pageAction.container.hidden);
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ assert.isNull(
+ pageAction.urlbar.getAttribute("cfr-recommendation-state")
+ );
+ });
+ it("should remove the `currentNotification`", () => {
+ const notification = {};
+ pageAction.currentNotification = notification;
+ pageAction.hideAddressBarNotifier();
+ assert.calledWith(global.PopupNotifications.remove, notification);
+ });
+ });
+
+ describe("#_expand", () => {
+ beforeEach(() => {
+ pageAction._clearScheduledStateChanges();
+ pageAction.urlbar.removeAttribute("cfr-recommendation-state");
+ });
+ it("without a delay, should clear other state changes and set the state to 'expanded'", () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ pageAction._expand();
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "expanded"
+ );
+ });
+ it("with a delay, should set the expanded state after the correct amount of time", () => {
+ const delay = 1234;
+ pageAction._expand(delay);
+ // We expect that an expansion has been scheduled
+ assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1);
+ clock.tick(delay + 1);
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "expanded"
+ );
+ });
+ });
+
+ describe("#_collapse", () => {
+ beforeEach(() => {
+ pageAction._clearScheduledStateChanges();
+ pageAction.urlbar.removeAttribute("cfr-recommendation-state");
+ });
+ it("without a delay, should clear other state changes and set the state to collapsed only if it's already expanded", () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ pageAction._collapse();
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ assert.isNull(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state")
+ );
+ pageAction.urlbarinput.setAttribute(
+ "cfr-recommendation-state",
+ "expanded"
+ );
+ pageAction._collapse();
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "collapsed"
+ );
+ });
+ it("with a delay, should set the collapsed state after the correct amount of time", () => {
+ const delay = 1234;
+ pageAction._collapse(delay);
+ clock.tick(delay + 1);
+ // The state was _not_ "expanded" and so should not have been set to "collapsed"
+ assert.isNull(
+ pageAction.urlbar.getAttribute("cfr-recommendation-state")
+ );
+
+ pageAction._expand();
+ pageAction._collapse(delay);
+ // We expect that a collapse has been scheduled
+ assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1);
+ clock.tick(delay + 1);
+ // This time it was "expanded" so should now (after the delay) be "collapsed"
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "collapsed"
+ );
+ });
+ });
+
+ describe("#_clearScheduledStateChanges", () => {
+ it("should call .clearTimeout on all stored timeoutIDs", () => {
+ pageAction.stateTransitionTimeoutIDs = [42, 73, 1997];
+ sandbox.spy(pageAction.window, "clearTimeout");
+ pageAction._clearScheduledStateChanges();
+ assert.calledThrice(pageAction.window.clearTimeout);
+ assert.calledWith(pageAction.window.clearTimeout, 42);
+ assert.calledWith(pageAction.window.clearTimeout, 73);
+ assert.calledWith(pageAction.window.clearTimeout, 1997);
+ });
+ });
+
+ describe("#_popupStateChange", () => {
+ it("should collapse the notification and send dismiss telemetry on 'dismissed'", () => {
+ pageAction._expand();
+
+ sandbox.spy(pageAction, "_sendTelemetry");
+
+ pageAction._popupStateChange("dismissed");
+ assert.equal(
+ pageAction.urlbarinput.getAttribute("cfr-recommendation-state"),
+ "collapsed"
+ );
+
+ assert.equal(
+ pageAction._sendTelemetry.lastCall.args[0].event,
+ "DISMISS"
+ );
+ });
+ it("should remove the notification on 'removed'", () => {
+ pageAction._expand();
+ const fakeNotification = {};
+
+ pageAction.currentNotification = fakeNotification;
+ pageAction._popupStateChange("removed");
+ assert.calledOnce(global.PopupNotifications.remove);
+ assert.calledWith(global.PopupNotifications.remove, fakeNotification);
+ });
+ it("should do nothing for other states", () => {
+ pageAction._popupStateChange("opened");
+ assert.notCalled(global.PopupNotifications.remove);
+ });
+ });
+
+ describe("#dispatchUserAction", () => {
+ it("should call ._dispatchCFRAction with the right action", () => {
+ const fakeAction = {};
+ pageAction.dispatchUserAction(fakeAction);
+ assert.calledOnce(dispatchStub);
+ assert.calledWith(
+ dispatchStub,
+ { type: "USER_ACTION", data: fakeAction },
+ fakeBrowser
+ );
+ });
+ });
+
+ describe("#_dispatchImpression", () => {
+ it("should call ._dispatchCFRAction with the right action", () => {
+ pageAction._dispatchImpression("fake impression");
+ assert.calledWith(dispatchStub, {
+ type: "IMPRESSION",
+ data: "fake impression",
+ });
+ });
+ });
+
+ describe("#_sendTelemetry", () => {
+ it("should call ._dispatchCFRAction with the right action", () => {
+ const fakePing = { message_id: 42 };
+ pageAction._sendTelemetry(fakePing);
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: 42,
+ },
+ });
+ });
+ });
+
+ describe("#_blockMessage", () => {
+ it("should call ._dispatchCFRAction with the right action", () => {
+ pageAction._blockMessage("fake id");
+ assert.calledOnce(dispatchStub);
+ assert.calledWith(dispatchStub, {
+ type: "BLOCK_MESSAGE_BY_ID",
+ data: { id: "fake id" },
+ });
+ });
+ });
+
+ describe("#getStrings", () => {
+ let formatMessagesStub;
+ const localeStrings = [
+ {
+ value: "你好世界",
+ attributes: [
+ { name: "first_attr", value: 42 },
+ { name: "second_attr", value: "some string" },
+ { name: "third_attr", value: [1, 2, 3] },
+ ],
+ },
+ ];
+
+ beforeEach(() => {
+ formatMessagesStub = sandbox
+ .stub()
+ .withArgs({ id: "hello_world" })
+ .resolves(localeStrings);
+ global.RemoteL10n.l10n.formatMessages = formatMessagesStub;
+ });
+
+ it("should return the argument if a string_id is not defined", async () => {
+ assert.deepEqual(await pageAction.getStrings({}), {});
+ assert.equal(await pageAction.getStrings("some string"), "some string");
+ });
+ it("should get the right locale string", async () => {
+ assert.equal(
+ await pageAction.getStrings({ string_id: "hello_world" }),
+ localeStrings[0].value
+ );
+ });
+ it("should return the right sub-attribute if specified", async () => {
+ assert.equal(
+ await pageAction.getStrings(
+ { string_id: "hello_world" },
+ "second_attr"
+ ),
+ "some string"
+ );
+ });
+ it("should attach attributes to string overrides", async () => {
+ const fromJson = { value: "Add Now", attributes: { accesskey: "A" } };
+
+ const result = await pageAction.getStrings(fromJson);
+
+ assert.equal(result, fromJson.value);
+ assert.propertyVal(result.attributes, "accesskey", "A");
+ });
+ it("should return subAttributes when doing string overrides", async () => {
+ const fromJson = { value: "Add Now", attributes: { accesskey: "A" } };
+
+ const result = await pageAction.getStrings(fromJson, "accesskey");
+
+ assert.equal(result, "A");
+ });
+ it("should resolve ftl strings and attach subAttributes", async () => {
+ const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" };
+ formatMessagesStub.resolves([
+ { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] },
+ ]);
+
+ const result = await pageAction.getStrings(fromFtl);
+
+ assert.equal(result, "Add Now");
+ assert.propertyVal(result.attributes, "accesskey", "A");
+ });
+ it("should return subAttributes from ftl ids", async () => {
+ const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" };
+ formatMessagesStub.resolves([
+ { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] },
+ ]);
+
+ const result = await pageAction.getStrings(fromFtl, "accesskey");
+
+ assert.equal(result, "A");
+ });
+ it("should report an error when no attributes are present but subAttribute is requested", async () => {
+ const fromJson = { value: "Foo" };
+ const stub = sandbox.stub(global.console, "error");
+
+ await pageAction.getStrings(fromJson, "accesskey");
+
+ assert.calledOnce(stub);
+ stub.restore();
+ });
+ });
+
+ describe("#_cfrUrlbarButtonClick", () => {
+ let translateElementsStub;
+ let setAttributesStub;
+ let getStringsStub;
+ beforeEach(async () => {
+ CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction);
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+ getStringsStub = sandbox.stub(pageAction, "getStrings").resolves("");
+ getStringsStub
+ .callsFake(async a => a) // eslint-disable-line max-nested-callbacks
+ .withArgs({ string_id: "primary_button_id" })
+ .resolves({ value: "Primary Button", attributes: { accesskey: "p" } })
+ .withArgs({ string_id: "secondary_button_id" })
+ .resolves({
+ value: "Secondary Button",
+ attributes: { accesskey: "s" },
+ })
+ .withArgs({ string_id: "secondary_button_id_2" })
+ .resolves({
+ value: "Secondary Button 2",
+ attributes: { accesskey: "a" },
+ })
+ .withArgs({ string_id: "secondary_button_id_3" })
+ .resolves({
+ value: "Secondary Button 3",
+ attributes: { accesskey: "g" },
+ })
+ .withArgs(
+ sinon.match({
+ string_id: "cfr-doorhanger-extension-learn-more-link",
+ })
+ )
+ .resolves("Learn more")
+ .withArgs(
+ sinon.match({ string_id: "cfr-doorhanger-extension-total-users" })
+ )
+ .callsFake(async ({ args }) => `${args.total} users`); // eslint-disable-line max-nested-callbacks
+
+ translateElementsStub = sandbox.stub().resolves();
+ setAttributesStub = sandbox.stub();
+ global.RemoteL10n.l10n.setAttributes = setAttributesStub;
+ global.RemoteL10n.l10n.translateElements = translateElementsStub;
+ });
+
+ it("should call `.hideAddressBarNotifier` and do nothing if there is no recommendation for the selected browser", async () => {
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ CFRPageActions.RecommendationMap.delete(fakeBrowser);
+ await pageAction._cfrUrlbarButtonClick({});
+ assert.calledOnce(pageAction.hideAddressBarNotifier);
+ assert.notCalled(global.PopupNotifications.show);
+ });
+ it("should cancel any planned state changes", async () => {
+ sandbox.spy(pageAction, "_clearScheduledStateChanges");
+ assert.notCalled(pageAction._clearScheduledStateChanges);
+ await pageAction._cfrUrlbarButtonClick({});
+ assert.calledOnce(pageAction._clearScheduledStateChanges);
+ });
+ it("should set the right text values", async () => {
+ await pageAction._cfrUrlbarButtonClick({});
+ const headerLabel = elements["cfr-notification-header-label"];
+ const headerLink = elements["cfr-notification-header-link"];
+ const headerImage = elements["cfr-notification-header-image"];
+ const footerLink = elements["cfr-notification-footer-learn-more-link"];
+ assert.equal(
+ headerLabel.value,
+ fakeRecommendation.content.heading_text
+ );
+ assert.isTrue(
+ headerLink
+ .getAttribute("href")
+ .endsWith(fakeRecommendation.content.info_icon.sumo_path)
+ );
+ assert.equal(
+ headerImage.getAttribute("tooltiptext"),
+ fakeRecommendation.content.info_icon.label
+ );
+ const htmlFooterEl = fakeRemoteL10n.createElement.args.find(
+ /* eslint-disable-next-line max-nested-callbacks */
+ ([doc, el, args]) =>
+ args && args.content === fakeRecommendation.content.text
+ );
+ assert.ok(htmlFooterEl);
+ assert.equal(footerLink.value, "Learn more");
+ assert.equal(
+ footerLink.getAttribute("href"),
+ fakeRecommendation.content.addon.amo_url
+ );
+ });
+ it("should add the rating correctly", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ const footerFilledStars =
+ elements["cfr-notification-footer-filled-stars"];
+ const footerEmptyStars =
+ elements["cfr-notification-footer-empty-stars"];
+ // .toFixed to sort out some floating precision errors
+ assert.equal(
+ footerFilledStars.style.width,
+ `${(4.2 * 16).toFixed(1)}px`
+ );
+ assert.equal(
+ footerEmptyStars.style.width,
+ `${(0.8 * 16).toFixed(1)}px`
+ );
+ });
+ it("should add the number of users correctly", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ const footerUsers = elements["cfr-notification-footer-users"];
+ assert.isNull(footerUsers.getAttribute("hidden"));
+ assert.equal(
+ footerUsers.getAttribute("value"),
+ `${fakeRecommendation.content.addon.users}`
+ );
+ });
+ it("should send the right telemetry", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "CLICK_DOORHANGER",
+ },
+ });
+ });
+ it("should set the main action correctly", async () => {
+ sinon
+ .stub(CFRPageActions, "_fetchLatestAddonVersion")
+ .resolves("latest-addon.xpi");
+ await pageAction._cfrUrlbarButtonClick();
+ const mainAction = global.PopupNotifications.show.firstCall.args[4]; // eslint-disable-line prefer-destructuring
+ assert.deepEqual(mainAction.label, {
+ value: "Primary Button",
+ attributes: { accesskey: "p" },
+ });
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ await mainAction.callback();
+ assert.calledOnce(pageAction.hideAddressBarNotifier);
+ // Should block the message
+ assert.calledWith(dispatchStub, {
+ type: "BLOCK_MESSAGE_BY_ID",
+ data: { id: fakeRecommendation.id },
+ });
+ // Should trigger the action
+ assert.calledWith(
+ dispatchStub,
+ {
+ type: "USER_ACTION",
+ data: { id: "primary_action", data: { url: "latest-addon.xpi" } },
+ },
+ fakeBrowser
+ );
+ // Should send telemetry
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "INSTALL",
+ },
+ });
+ // Should remove the recommendation
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should set the secondary action correctly", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ // eslint-disable-next-line prefer-destructuring
+ const [secondaryAction] =
+ global.PopupNotifications.show.firstCall.args[5];
+
+ assert.deepEqual(secondaryAction.label, {
+ value: "Secondary Button",
+ attributes: { accesskey: "s" },
+ });
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ CFRPageActions.RecommendationMap.set(fakeBrowser, {});
+ secondaryAction.callback();
+ // Should send telemetry
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "DISMISS",
+ },
+ });
+ // Don't remove the recommendation on `DISMISS` action
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ assert.notCalled(pageAction.hideAddressBarNotifier);
+ });
+ it("should send right telemetry for BLOCK secondary action", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ // eslint-disable-next-line prefer-destructuring
+ const blockAction = global.PopupNotifications.show.firstCall.args[5][1];
+
+ assert.deepEqual(blockAction.label, {
+ value: "Secondary Button 2",
+ attributes: { accesskey: "a" },
+ });
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ sandbox.spy(pageAction, "_blockMessage");
+ CFRPageActions.RecommendationMap.set(fakeBrowser, {});
+ blockAction.callback();
+ assert.calledOnce(pageAction.hideAddressBarNotifier);
+ assert.calledOnce(pageAction._blockMessage);
+ // Should send telemetry
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "BLOCK",
+ },
+ });
+ // Should remove the recommendation
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should send right telemetry for MANAGE secondary action", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ // eslint-disable-next-line prefer-destructuring
+ const manageAction =
+ global.PopupNotifications.show.firstCall.args[5][2];
+
+ assert.deepEqual(manageAction.label, {
+ value: "Secondary Button 3",
+ attributes: { accesskey: "g" },
+ });
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+ CFRPageActions.RecommendationMap.set(fakeBrowser, {});
+ manageAction.callback();
+ // Should send telemetry
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "MANAGE",
+ },
+ });
+ // Don't remove the recommendation on `MANAGE` action
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ assert.notCalled(pageAction.hideAddressBarNotifier);
+ });
+ it("should call PopupNotifications.show with the right arguments", async () => {
+ await pageAction._cfrUrlbarButtonClick();
+ assert.calledWith(
+ global.PopupNotifications.show,
+ fakeBrowser,
+ "contextual-feature-recommendation",
+ fakeRecommendation.content.addon.title,
+ "cfr",
+ sinon.match.any, // Corresponds to the main action, tested above
+ sinon.match.any, // Corresponds to the secondary action, tested above
+ {
+ popupIconURL: fakeRecommendation.content.addon.icon,
+ hideClose: true,
+ eventCallback: pageAction._popupStateChange,
+ persistent: false,
+ persistWhileVisible: false,
+ popupIconClass: fakeRecommendation.content.icon_class,
+ recordTelemetryInPrivateBrowsing:
+ fakeRecommendation.content.show_in_private_browsing,
+ name: {
+ string_id: "cfr-doorhanger-extension-author",
+ args: { name: fakeRecommendation.content.addon.author },
+ },
+ }
+ );
+ });
+ });
+ describe("#_cfrUrlbarButtonClick/cfr_urlbar_chiclet", () => {
+ let heartbeatRecommendation;
+ beforeEach(async () => {
+ heartbeatRecommendation = (await CFRMessageProvider.getMessages()).find(
+ m => m.template === "cfr_urlbar_chiclet"
+ );
+ CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction);
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ heartbeatRecommendation,
+ dispatchStub
+ );
+ });
+ it("should dispatch a click event", async () => {
+ await pageAction._cfrUrlbarButtonClick({});
+
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ message_id: heartbeatRecommendation.id,
+ bucket_id: heartbeatRecommendation.content.bucket_id,
+ event: "CLICK_DOORHANGER",
+ },
+ });
+ });
+ it("should dispatch a USER_ACTION for chiclet_open_url layout", async () => {
+ await pageAction._cfrUrlbarButtonClick({});
+
+ assert.calledWith(dispatchStub, {
+ type: "USER_ACTION",
+ data: {
+ data: {
+ args: heartbeatRecommendation.content.action.url,
+ where: heartbeatRecommendation.content.action.where,
+ },
+ type: "OPEN_URL",
+ },
+ });
+ });
+ it("should block the message after the click", async () => {
+ await pageAction._cfrUrlbarButtonClick({});
+
+ assert.calledWith(dispatchStub, {
+ type: "BLOCK_MESSAGE_BY_ID",
+ data: { id: heartbeatRecommendation.id },
+ });
+ });
+ it("should remove the button and browser entry", async () => {
+ sandbox.spy(pageAction, "hideAddressBarNotifier");
+
+ await pageAction._cfrUrlbarButtonClick({});
+
+ assert.calledOnce(pageAction.hideAddressBarNotifier);
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ });
+ });
+
+ describe("CFRPageActions", () => {
+ beforeEach(() => {
+ // Spy on the prototype methods to inspect calls for any PageAction instance
+ sandbox.spy(PageAction.prototype, "showAddressBarNotifier");
+ sandbox.spy(PageAction.prototype, "hideAddressBarNotifier");
+ });
+
+ describe("updatePageActions", () => {
+ let savedRec;
+
+ beforeEach(() => {
+ const win = fakeBrowser.ownerGlobal;
+ CFRPageActions.PageActionMap.set(
+ win,
+ new PageAction(win, dispatchStub)
+ );
+ const { id, content } = fakeRecommendation;
+ savedRec = {
+ id,
+ host: fakeHost,
+ content,
+ };
+ CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec);
+ });
+
+ it("should do nothing if a pageAction doesn't exist for the window", () => {
+ const win = fakeBrowser.ownerGlobal;
+ CFRPageActions.PageActionMap.delete(win);
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.notCalled(PageAction.prototype.showAddressBarNotifier);
+ assert.notCalled(PageAction.prototype.hideAddressBarNotifier);
+ });
+ it("should do nothing if the browser is not the `selectedBrowser`", () => {
+ const someOtherFakeBrowser = {};
+ CFRPageActions.updatePageActions(someOtherFakeBrowser);
+ assert.notCalled(PageAction.prototype.showAddressBarNotifier);
+ assert.notCalled(PageAction.prototype.hideAddressBarNotifier);
+ });
+ it("should hideAddressBarNotifier the pageAction if a recommendation doesn't exist for the given browser", () => {
+ CFRPageActions.RecommendationMap.delete(fakeBrowser);
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
+ });
+ it("should show the pageAction if a recommendation exists and the host matches", () => {
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
+ assert.calledWith(
+ PageAction.prototype.showAddressBarNotifier,
+ savedRec
+ );
+ });
+ it("should show the pageAction if a recommendation exists and it doesn't have a host defined", () => {
+ const recNoHost = { ...savedRec, host: undefined };
+ CFRPageActions.RecommendationMap.set(fakeBrowser, recNoHost);
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
+ assert.calledWith(
+ PageAction.prototype.showAddressBarNotifier,
+ recNoHost
+ );
+ });
+ it("should hideAddressBarNotifier the pageAction and delete the recommendation if the recommendation exists but the host doesn't match", () => {
+ const someOtherFakeHost = "subdomain.mozilla.com";
+ fakeBrowser.documentURI.host = someOtherFakeHost;
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should not call `delete` if retain is true", () => {
+ savedRec.retain = true;
+ fakeBrowser.documentURI.host = "subdomain.mozilla.com";
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.propertyVal(savedRec, "retain", false);
+ assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should call `delete` if retain is false", () => {
+ savedRec.retain = false;
+ fakeBrowser.documentURI.host = "subdomain.mozilla.com";
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+
+ CFRPageActions.updatePageActions(fakeBrowser);
+ assert.propertyVal(savedRec, "retain", false);
+ assert.calledOnce(PageAction.prototype.hideAddressBarNotifier);
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ });
+
+ describe("forceRecommendation", () => {
+ it("should succeed and add an element to the RecommendationMap", async () => {
+ assert.isTrue(
+ await CFRPageActions.forceRecommendation(
+ fakeBrowser,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
+ id: fakeRecommendation.id,
+ content: fakeRecommendation.content,
+ });
+ });
+ it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => {
+ const win = fakeBrowser.ownerGlobal;
+ assert.isFalse(CFRPageActions.PageActionMap.has(win));
+ await CFRPageActions.forceRecommendation(
+ fakeBrowser,
+ fakeRecommendation,
+ dispatchStub
+ );
+ const pageAction = CFRPageActions.PageActionMap.get(win);
+ assert.equal(win, pageAction.window);
+ assert.equal(dispatchStub, pageAction._dispatchCFRAction);
+ assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
+ });
+ });
+
+ describe("showPopup", () => {
+ let savedRec;
+ let pageAction;
+ let fakeAnchorId = "fake_anchor_id";
+ let sandboxShowPopup = sinon.createSandbox();
+ let fakePopUp = {
+ id: "fake_id",
+ template: "cfr_doorhanger",
+ content: {
+ skip_address_bar_notifier: true,
+ heading_text: "Fake Heading Text",
+ anchor_id: "fake_anchor_id",
+ },
+ };
+ beforeEach(() => {
+ const { id, content } = fakePopUp;
+ savedRec = {
+ id,
+ host: fakeHost,
+ content,
+ };
+ CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec);
+ pageAction = new PageAction(window, dispatchStub);
+
+ sandboxShowPopup.stub(window.document, "getElementById");
+ sandboxShowPopup.stub(pageAction, "_renderPopup");
+ globals.set({
+ CustomizableUI: {
+ getWidget: sandboxShowPopup
+ .stub()
+ .withArgs(fakeAnchorId)
+ .returns({ areaType: "menu-panel" }),
+ },
+ });
+ });
+ afterEach(() => {
+ sandboxShowPopup.restore();
+ globals.restore();
+ });
+
+ it("Should use default anchor_id if an alternate hasn't been provided", async () => {
+ await pageAction.showPopup();
+
+ assert.calledWith(window.document.getElementById, fakeAnchorId);
+ });
+
+ it("Should use alt_anchor_if if one has been provided AND the anchor_id has been removed", async () => {
+ let fakeAltAnchorId = "fake_alt_anchor_id";
+
+ fakePopUp.content.alt_anchor_id = fakeAltAnchorId;
+ await pageAction.showPopup();
+ assert.calledWith(window.document.getElementById, fakeAltAnchorId);
+ });
+ });
+
+ describe("addRecommendation", () => {
+ it("should fail and not add a recommendation if the browser is part of a private window", async () => {
+ global.PrivateBrowsingUtils.isWindowPrivate.returns(true);
+ assert.isFalse(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should successfully add a private browsing recommendation and send correct telemetry", async () => {
+ global.PrivateBrowsingUtils.isWindowPrivate.returns(true);
+ fakeRecommendation.content.show_in_private_browsing = true;
+ assert.isTrue(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser));
+
+ const pageAction = CFRPageActions.PageActionMap.get(
+ fakeBrowser.ownerGlobal
+ );
+ await pageAction.showAddressBarNotifier(fakeRecommendation, true);
+ assert.calledWith(dispatchStub, {
+ type: "DOORHANGER_TELEMETRY",
+ data: {
+ action: "cfr_user_event",
+ source: "CFR",
+ is_private: true,
+ message_id: fakeRecommendation.id,
+ bucket_id: fakeRecommendation.content.bucket_id,
+ event: "IMPRESSION",
+ },
+ });
+ });
+ it("should fail and not add a recommendation if the browser is not the selected browser", async () => {
+ global.gBrowser.selectedBrowser = {}; // Some other browser
+ assert.isFalse(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ });
+ it("should fail and not add a recommendation if the browser does not exist", async () => {
+ assert.isFalse(
+ await CFRPageActions.addRecommendation(
+ undefined,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser));
+ });
+ it("should fail and not add a recommendation if the host doesn't match", async () => {
+ const someOtherFakeHost = "subdomain.mozilla.com";
+ assert.isFalse(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ someOtherFakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ });
+ it("should otherwise succeed and add an element to the RecommendationMap", async () => {
+ assert.isTrue(
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ )
+ );
+ assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
+ id: fakeRecommendation.id,
+ host: fakeHost,
+ content: fakeRecommendation.content,
+ });
+ });
+ it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => {
+ const win = fakeBrowser.ownerGlobal;
+ assert.isFalse(CFRPageActions.PageActionMap.has(win));
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+ const pageAction = CFRPageActions.PageActionMap.get(win);
+ assert.equal(win, pageAction.window);
+ assert.equal(dispatchStub, pageAction._dispatchCFRAction);
+ assert.calledOnce(PageAction.prototype.showAddressBarNotifier);
+ });
+ it("should add the right url if we fetched and addon install URL", async () => {
+ fakeRecommendation.template = "cfr_doorhanger";
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+ const recommendation =
+ CFRPageActions.RecommendationMap.get(fakeBrowser);
+
+ // sanity check - just go through some of the rest of the attributes to make sure they were untouched
+ assert.equal(recommendation.id, fakeRecommendation.id);
+ assert.equal(
+ recommendation.content.heading_text,
+ fakeRecommendation.content.heading_text
+ );
+ assert.equal(
+ recommendation.content.addon,
+ fakeRecommendation.content.addon
+ );
+ assert.equal(
+ recommendation.content.text,
+ fakeRecommendation.content.text
+ );
+ assert.equal(
+ recommendation.content.buttons.secondary,
+ fakeRecommendation.content.buttons.secondary
+ );
+ assert.equal(
+ recommendation.content.buttons.primary.action.id,
+ fakeRecommendation.content.buttons.primary.action.id
+ );
+
+ delete fakeRecommendation.template;
+ });
+ it("should prevent a second message if one is currently displayed", async () => {
+ const secondMessage = { ...fakeRecommendation, id: "second_message" };
+ let messageAdded = await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+
+ assert.isTrue(messageAdded);
+ assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
+ id: fakeRecommendation.id,
+ host: fakeHost,
+ content: fakeRecommendation.content,
+ });
+
+ messageAdded = await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ secondMessage,
+ dispatchStub
+ );
+ // Adding failed
+ assert.isFalse(messageAdded);
+ // First message is still there
+ assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), {
+ id: fakeRecommendation.id,
+ host: fakeHost,
+ content: fakeRecommendation.content,
+ });
+ });
+ it("should send impressions just for the first message", async () => {
+ const secondMessage = { ...fakeRecommendation, id: "second_message" };
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ fakeRecommendation,
+ dispatchStub
+ );
+ await CFRPageActions.addRecommendation(
+ fakeBrowser,
+ fakeHost,
+ secondMessage,
+ dispatchStub
+ );
+
+ // Doorhanger telemetry + Impression for just 1 message
+ assert.calledTwice(dispatchStub);
+ const [firstArgs] = dispatchStub.firstCall.args;
+ const [secondArgs] = dispatchStub.secondCall.args;
+ assert.equal(firstArgs.data.id, secondArgs.data.message_id);
+ });
+ });
+
+ describe("clearRecommendations", () => {
+ const createFakePageAction = () => ({
+ hideAddressBarNotifier: sandbox.stub(),
+ });
+ const windows = [{}, {}, { closed: true }];
+ const browsers = [{}, {}, {}, {}];
+
+ beforeEach(() => {
+ CFRPageActions.PageActionMap.set(windows[0], createFakePageAction());
+ CFRPageActions.PageActionMap.set(windows[2], createFakePageAction());
+ for (const browser of browsers) {
+ CFRPageActions.RecommendationMap.set(browser, {});
+ }
+ globals.set({ Services: { wm: { getEnumerator: () => windows } } });
+ });
+
+ it("should hideAddressBarNotifier the PageActions of any existing, non-closed windows", () => {
+ const pageActions = windows.map(win =>
+ CFRPageActions.PageActionMap.get(win)
+ );
+ CFRPageActions.clearRecommendations();
+
+ // Only the first window had a PageAction and wasn't closed
+ assert.calledOnce(pageActions[0].hideAddressBarNotifier);
+ assert.isUndefined(pageActions[1]);
+ assert.notCalled(pageActions[2].hideAddressBarNotifier);
+ });
+ it("should clear the PageActionMap and the RecommendationMap", () => {
+ CFRPageActions.clearRecommendations();
+
+ // Both are WeakMaps and so are not iterable, cannot be cleared, and
+ // cannot have their length queried directly, so we have to check
+ // whether previous elements still exist
+ assert.lengthOf(windows, 3);
+ for (const win of windows) {
+ assert.isFalse(CFRPageActions.PageActionMap.has(win));
+ }
+ assert.lengthOf(browsers, 4);
+ for (const browser of browsers) {
+ assert.isFalse(CFRPageActions.RecommendationMap.has(browser));
+ }
+ });
+ });
+
+ describe("reloadL10n", () => {
+ const createFakePageAction = () => ({
+ hideAddressBarNotifier() {},
+ reloadL10n: sandbox.stub(),
+ });
+ const windows = [{}, {}, { closed: true }];
+
+ beforeEach(() => {
+ CFRPageActions.PageActionMap.set(windows[0], createFakePageAction());
+ CFRPageActions.PageActionMap.set(windows[2], createFakePageAction());
+ globals.set({ Services: { wm: { getEnumerator: () => windows } } });
+ });
+
+ it("should call reloadL10n for all the PageActions of any existing, non-closed windows", () => {
+ const pageActions = windows.map(win =>
+ CFRPageActions.PageActionMap.get(win)
+ );
+ CFRPageActions.reloadL10n();
+
+ // Only the first window had a PageAction and wasn't closed
+ assert.calledOnce(pageActions[0].reloadL10n);
+ assert.isUndefined(pageActions[1]);
+ assert.notCalled(pageActions[2].reloadL10n);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js b/browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js
new file mode 100644
index 0000000000..d855f89d27
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js
@@ -0,0 +1,459 @@
+import { MessageLoaderUtils } from "lib/ASRouter.jsm";
+const { STARTPAGE_VERSION } = MessageLoaderUtils;
+
+const FAKE_OPTIONS = {
+ storage: {
+ set() {
+ return Promise.resolve();
+ },
+ get() {
+ return Promise.resolve();
+ },
+ },
+ dispatchToAS: () => {},
+};
+const FAKE_RESPONSE_HEADERS = { get() {} };
+
+describe("MessageLoaderUtils", () => {
+ let fetchStub;
+ let clock;
+ let sandbox;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ clock = sinon.useFakeTimers();
+ fetchStub = sinon.stub(global, "fetch");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ clock.restore();
+ fetchStub.restore();
+ });
+
+ describe("#loadMessagesForProvider", () => {
+ it("should return messages for a local provider with hardcoded messages", async () => {
+ const sourceMessage = { id: "foo" };
+ const provider = {
+ id: "provider123",
+ type: "local",
+ messages: [sourceMessage],
+ };
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.isArray(result.messages);
+ // Does the message have the right properties?
+ const [message] = result.messages;
+ assert.propertyVal(message, "id", "foo");
+ assert.propertyVal(message, "provider", "provider123");
+ });
+ it("should filter out local messages listed in the `exclude` field", async () => {
+ const sourceMessage = { id: "foo" };
+ const provider = {
+ id: "provider123",
+ type: "local",
+ messages: [sourceMessage],
+ exclude: ["foo"],
+ };
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.lengthOf(result.messages, 0);
+ });
+ it("should return messages for remote provider", async () => {
+ const sourceMessage = { id: "foo" };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ messages: [sourceMessage] }),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const provider = {
+ id: "provider123",
+ type: "remote",
+ url: "https://foo.com",
+ };
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.isArray(result.messages);
+ // Does the message have the right properties?
+ const [message] = result.messages;
+ assert.propertyVal(message, "id", "foo");
+ assert.propertyVal(message, "provider", "provider123");
+ assert.propertyVal(message, "provider_url", "https://foo.com");
+ });
+ describe("remote provider HTTP codes", () => {
+ const testMessage = { id: "foo" };
+ const provider = {
+ id: "provider123",
+ type: "remote",
+ url: "https://foo.com",
+ updateCycleInMs: 300,
+ };
+ const respJson = { messages: [testMessage] };
+
+ function assertReturnsCorrectMessages(actual) {
+ assert.isArray(actual.messages);
+ // Does the message have the right properties?
+ const [message] = actual.messages;
+ assert.propertyVal(message, "id", testMessage.id);
+ assert.propertyVal(message, "provider", provider.id);
+ assert.propertyVal(message, "provider_url", provider.url);
+ }
+
+ it("should return messages for 200 response", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(respJson),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ assertReturnsCorrectMessages(
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ )
+ );
+ });
+
+ it("should return messages for a 302 response with json", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 302,
+ json: () => Promise.resolve(respJson),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ assertReturnsCorrectMessages(
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ )
+ );
+ });
+
+ it("should return an empty array for a 204 response", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 204,
+ json: () => "",
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.deepEqual(result.messages, []);
+ });
+
+ it("should return an empty array for a 500 response", async () => {
+ fetchStub.resolves({
+ ok: false,
+ status: 500,
+ json: () => "",
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.deepEqual(result.messages, []);
+ });
+
+ it("should return cached messages for a 304 response", async () => {
+ clock.tick(302);
+ const messages = [{ id: "message-1" }, { id: "message-2" }];
+ const fakeStorage = {
+ set() {
+ return Promise.resolve();
+ },
+ get() {
+ return Promise.resolve({
+ [provider.id]: {
+ version: STARTPAGE_VERSION,
+ url: provider.url,
+ messages,
+ etag: "etag0987654321",
+ lastFetched: 1,
+ },
+ });
+ },
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 304,
+ json: () => "",
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ { ...FAKE_OPTIONS, storage: fakeStorage }
+ );
+ assert.equal(result.messages.length, messages.length);
+ messages.forEach(message => {
+ assert.ok(result.messages.find(m => m.id === message.id));
+ });
+ });
+
+ it("should return an empty array if json doesn't parse properly", async () => {
+ fetchStub.resolves({
+ ok: false,
+ status: 200,
+ json: () => "",
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.deepEqual(result.messages, []);
+ });
+
+ it("should report response parsing errors with MessageLoaderUtils.reportError", async () => {
+ const err = {};
+ sandbox.spy(MessageLoaderUtils, "reportError");
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: sandbox.stub().rejects(err),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.calledOnce(MessageLoaderUtils.reportError);
+ // Report that json parsing failed
+ assert.calledWith(MessageLoaderUtils.reportError, err);
+ });
+
+ it("should report missing `messages` with MessageLoaderUtils.reportError", async () => {
+ sandbox.spy(MessageLoaderUtils, "reportError");
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: sandbox.stub().resolves({}),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.calledOnce(MessageLoaderUtils.reportError);
+ // Report no messages returned
+ assert.calledWith(
+ MessageLoaderUtils.reportError,
+ "No messages returned from https://foo.com."
+ );
+ });
+
+ it("should report bad status responses with MessageLoaderUtils.reportError", async () => {
+ sandbox.spy(MessageLoaderUtils, "reportError");
+ fetchStub.resolves({
+ ok: false,
+ status: 500,
+ json: sandbox.stub().resolves({}),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.calledOnce(MessageLoaderUtils.reportError);
+ // Report no messages returned
+ assert.calledWith(
+ MessageLoaderUtils.reportError,
+ "Invalid response status 500 from https://foo.com."
+ );
+ });
+
+ it("should return an empty array if the request rejects", async () => {
+ fetchStub.rejects(new Error("something went wrong"));
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+ assert.deepEqual(result.messages, []);
+ });
+ });
+ describe("remote provider caching", () => {
+ const provider = {
+ id: "provider123",
+ type: "remote",
+ url: "https://foo.com",
+ updateCycleInMs: 300,
+ };
+
+ it("should return cached results if they aren't expired", async () => {
+ clock.tick(1);
+ const messages = [{ id: "message-1" }, { id: "message-2" }];
+ const fakeStorage = {
+ set() {
+ return Promise.resolve();
+ },
+ get() {
+ return Promise.resolve({
+ [provider.id]: {
+ version: STARTPAGE_VERSION,
+ url: provider.url,
+ messages,
+ etag: "etag0987654321",
+ lastFetched: Date.now(),
+ },
+ });
+ },
+ };
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ { ...FAKE_OPTIONS, storage: fakeStorage }
+ );
+ assert.equal(result.messages.length, messages.length);
+ messages.forEach(message => {
+ assert.ok(result.messages.find(m => m.id === message.id));
+ });
+ });
+
+ it("should return fetch results if the cache messages are expired", async () => {
+ clock.tick(302);
+ const testMessage = { id: "foo" };
+ const respJson = { messages: [testMessage] };
+ const fakeStorage = {
+ set() {
+ return Promise.resolve();
+ },
+ get() {
+ return Promise.resolve({
+ [provider.id]: {
+ version: STARTPAGE_VERSION,
+ url: provider.url,
+ messages: [{ id: "message-1" }, { id: "message-2" }],
+ etag: "etag0987654321",
+ lastFetched: 1,
+ },
+ });
+ },
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(respJson),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ { ...FAKE_OPTIONS, storage: fakeStorage }
+ );
+ assert.equal(result.messages.length, 1);
+ assert.equal(result.messages[0].id, testMessage.id);
+ });
+ });
+ it("should return an empty array for a remote provider with a blank URL without attempting a request", async () => {
+ const provider = { id: "provider123", type: "remote", url: "" };
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.notCalled(fetchStub);
+ assert.deepEqual(result.messages, []);
+ });
+ it("should return .lastUpdated with the time at which the messages were fetched", async () => {
+ const sourceMessage = { id: "foo" };
+ const provider = {
+ id: "provider123",
+ type: "remote",
+ url: "foo.com",
+ };
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () =>
+ new Promise(resolve => {
+ clock.tick(42);
+ resolve({ messages: [sourceMessage] });
+ }),
+ headers: FAKE_RESPONSE_HEADERS,
+ });
+
+ const result = await MessageLoaderUtils.loadMessagesForProvider(
+ provider,
+ FAKE_OPTIONS
+ );
+
+ assert.propertyVal(result, "lastUpdated", 42);
+ });
+ });
+
+ describe("#shouldProviderUpdate", () => {
+ it("should return true if the provider does not had a .lastUpdated property", () => {
+ assert.isTrue(MessageLoaderUtils.shouldProviderUpdate({ id: "foo" }));
+ });
+ it("should return false if the provider does not had a .updateCycleInMs property and has a .lastUpdated", () => {
+ clock.tick(1);
+ assert.isFalse(
+ MessageLoaderUtils.shouldProviderUpdate({ id: "foo", lastUpdated: 0 })
+ );
+ });
+ it("should return true if the time since .lastUpdated is greater than .updateCycleInMs", () => {
+ clock.tick(301);
+ assert.isTrue(
+ MessageLoaderUtils.shouldProviderUpdate({
+ id: "foo",
+ lastUpdated: 0,
+ updateCycleInMs: 300,
+ })
+ );
+ });
+ it("should return false if the time since .lastUpdated is less than .updateCycleInMs", () => {
+ clock.tick(299);
+ assert.isFalse(
+ MessageLoaderUtils.shouldProviderUpdate({
+ id: "foo",
+ lastUpdated: 0,
+ updateCycleInMs: 300,
+ })
+ );
+ });
+ });
+
+ describe("#cleanupCache", () => {
+ it("should remove data for providers no longer active", async () => {
+ const fakeStorage = {
+ get: sinon.stub().returns(
+ Promise.resolve({
+ "id-1": {},
+ "id-2": {},
+ "id-3": {},
+ })
+ ),
+ set: sinon.stub().returns(Promise.resolve()),
+ };
+ const fakeProviders = [
+ { id: "id-1", type: "remote" },
+ { id: "id-3", type: "remote" },
+ ];
+
+ await MessageLoaderUtils.cleanupCache(fakeProviders, fakeStorage);
+
+ assert.calledOnce(fakeStorage.set);
+ assert.calledWith(
+ fakeStorage.set,
+ MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY,
+ { "id-1": {}, "id-3": {} }
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx b/browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx
new file mode 100644
index 0000000000..889d26a9d3
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx
@@ -0,0 +1,69 @@
+import { ModalOverlayWrapper } from "content-src/asrouter/components/ModalOverlay/ModalOverlay";
+import { mount } from "enzyme";
+import React from "react";
+
+describe("ModalOverlayWrapper", () => {
+ let fakeDoc;
+ let sandbox;
+ let header;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ header = document.createElement("div");
+
+ fakeDoc = {
+ addEventListener: sandbox.stub(),
+ removeEventListener: sandbox.stub(),
+ body: { classList: { add: sandbox.stub(), remove: sandbox.stub() } },
+ getElementById() {
+ return header;
+ },
+ };
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should add eventListener and a class on mount", async () => {
+ mount(<ModalOverlayWrapper document={fakeDoc} />);
+ assert.calledOnce(fakeDoc.addEventListener);
+ assert.calledWith(fakeDoc.body.classList.add, "modal-open");
+ });
+
+ it("should remove eventListener on unmount", async () => {
+ const wrapper = mount(<ModalOverlayWrapper document={fakeDoc} />);
+ wrapper.unmount();
+ assert.calledOnce(fakeDoc.addEventListener);
+ assert.calledOnce(fakeDoc.removeEventListener);
+ assert.calledWith(fakeDoc.body.classList.remove, "modal-open");
+ });
+
+ it("should call props.onClose on an Escape key", async () => {
+ const onClose = sandbox.stub();
+ mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />);
+
+ // Simulate onkeydown being called
+ const [, callback] = fakeDoc.addEventListener.firstCall.args;
+ callback({ key: "Escape" });
+
+ assert.calledOnce(onClose);
+ });
+
+ it("should not call props.onClose on other keys than Escape", async () => {
+ const onClose = sandbox.stub();
+ mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />);
+
+ // Simulate onkeydown being called
+ const [, callback] = fakeDoc.addEventListener.firstCall.args;
+ callback({ key: "Ctrl" });
+
+ assert.notCalled(onClose);
+ });
+
+ it("should not call props.onClose when clicked outside dialog", async () => {
+ const onClose = sandbox.stub();
+ const wrapper = mount(
+ <ModalOverlayWrapper document={fakeDoc} onClose={onClose} />
+ );
+ wrapper.find("div.modalOverlayOuter.active").simulate("click");
+ assert.notCalled(onClose);
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js b/browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js
new file mode 100644
index 0000000000..34adfc88f1
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js
@@ -0,0 +1,217 @@
+import { RemoteL10n, _RemoteL10n } from "lib/RemoteL10n.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("RemoteL10n", () => {
+ let sandbox;
+ let globals;
+ let domL10nStub;
+ let l10nRegStub;
+ let l10nRegInstance;
+ let fileSourceStub;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ domL10nStub = sandbox.stub();
+ l10nRegInstance = {
+ hasSource: sandbox.stub(),
+ registerSources: sandbox.stub(),
+ removeSources: sandbox.stub(),
+ };
+
+ fileSourceStub = sandbox.stub();
+ l10nRegStub = {
+ getInstance: () => {
+ return l10nRegInstance;
+ },
+ };
+ globals.set("DOMLocalization", domL10nStub);
+ globals.set("L10nRegistry", l10nRegStub);
+ globals.set("L10nFileSource", fileSourceStub);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+ describe("#RemoteL10n", () => {
+ it("should create a new instance", () => {
+ assert.ok(new _RemoteL10n());
+ });
+ it("should create a DOMLocalization instance", () => {
+ domL10nStub.returns({ instance: true });
+ const instance = new _RemoteL10n();
+
+ assert.propertyVal(instance._createDOML10n(), "instance", true);
+ assert.calledOnce(domL10nStub);
+ });
+ it("should create a new instance", () => {
+ domL10nStub.returns({ instance: true });
+ const instance = new _RemoteL10n();
+
+ assert.ok(instance.l10n);
+
+ instance.reloadL10n();
+
+ assert.ok(instance.l10n);
+
+ assert.calledTwice(domL10nStub);
+ });
+ it("should reuse the instance", () => {
+ domL10nStub.returns({ instance: true });
+ const instance = new _RemoteL10n();
+
+ assert.ok(instance.l10n);
+ assert.ok(instance.l10n);
+
+ assert.calledOnce(domL10nStub);
+ });
+ });
+ describe("#_createDOML10n", () => {
+ it("should load the remote Fluent file if USE_REMOTE_L10N_PREF is true", async () => {
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
+ l10nRegInstance.hasSource.returns(false);
+ RemoteL10n._createDOML10n();
+
+ assert.calledOnce(domL10nStub);
+ const { args } = domL10nStub.firstCall;
+ // The first arg is the resource array,
+ // the second one is false (use async),
+ // and the third one is the bundle generator.
+ assert.equal(args.length, 2);
+ assert.deepEqual(args[0], [
+ "branding/brand.ftl",
+ "browser/defaultBrowserNotification.ftl",
+ "browser/newtab/asrouter.ftl",
+ "toolkit/branding/accounts.ftl",
+ "toolkit/branding/brandings.ftl",
+ ]);
+ assert.isFalse(args[1]);
+ assert.calledOnce(l10nRegInstance.hasSource);
+ assert.calledOnce(l10nRegInstance.registerSources);
+ assert.notCalled(l10nRegInstance.removeSources);
+ });
+ it("should load the local Fluent file if USE_REMOTE_L10N_PREF is false", () => {
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(false);
+ l10nRegInstance.hasSource.returns(true);
+ RemoteL10n._createDOML10n();
+
+ const { args } = domL10nStub.firstCall;
+ // The first arg is the resource array,
+ // the second one is false (use async),
+ // and the third one is null.
+ assert.equal(args.length, 2);
+ assert.deepEqual(args[0], [
+ "branding/brand.ftl",
+ "browser/defaultBrowserNotification.ftl",
+ "browser/newtab/asrouter.ftl",
+ "toolkit/branding/accounts.ftl",
+ "toolkit/branding/brandings.ftl",
+ ]);
+ assert.isFalse(args[1]);
+ assert.calledOnce(l10nRegInstance.hasSource);
+ assert.notCalled(l10nRegInstance.registerSources);
+ assert.calledOnce(l10nRegInstance.removeSources);
+ });
+ });
+ describe("#createElement", () => {
+ let doc;
+ let instance;
+ let setStringStub;
+ let elem;
+ beforeEach(() => {
+ elem = document.createElement("div");
+ doc = {
+ createElement: sandbox.stub().returns(elem),
+ createElementNS: sandbox.stub().returns(elem),
+ };
+ instance = new _RemoteL10n();
+ setStringStub = sandbox.stub(instance, "setString");
+ });
+ it("should call createElement if string_id is defined", () => {
+ instance.createElement(doc, "span", { content: { string_id: "foo" } });
+
+ assert.calledOnce(doc.createElement);
+ });
+ it("should call createElementNS if string_id is not present", () => {
+ instance.createElement(doc, "span", { content: "foo" });
+
+ assert.calledOnce(doc.createElementNS);
+ });
+ it("should set classList", () => {
+ instance.createElement(doc, "span", { classList: "foo" });
+
+ assert.isTrue(elem.classList.contains("foo"));
+ });
+ it("should call setString", () => {
+ const options = { classList: "foo" };
+ instance.createElement(doc, "span", options);
+
+ assert.calledOnce(setStringStub);
+ assert.calledWithExactly(setStringStub, elem, options);
+ });
+ });
+ describe("#setString", () => {
+ let instance;
+ beforeEach(() => {
+ instance = new _RemoteL10n();
+ });
+ it("should set fluent variables and id", () => {
+ let el = { setAttribute: sandbox.stub() };
+ instance.setString(el, {
+ content: { string_id: "foo" },
+ attributes: { bar: "bar", baz: "baz" },
+ });
+
+ assert.calledThrice(el.setAttribute);
+ assert.calledWithExactly(el.setAttribute, "fluent-variable-bar", "bar");
+ assert.calledWithExactly(el.setAttribute, "fluent-variable-baz", "baz");
+ assert.calledWithExactly(el.setAttribute, "fluent-remote-id", "foo");
+ });
+ it("should set content if no string_id", () => {
+ let el = { setAttribute: sandbox.stub() };
+ instance.setString(el, { content: "foo" });
+
+ assert.notCalled(el.setAttribute);
+ assert.equal(el.textContent, "foo");
+ });
+ });
+ describe("#isLocaleSupported", () => {
+ it("should return true if the locale is en-US", () => {
+ assert.ok(RemoteL10n.isLocaleSupported("en-US"));
+ });
+ it("should return true if the locale is in all-locales", () => {
+ assert.ok(RemoteL10n.isLocaleSupported("en-CA"));
+ });
+ it("should return false if the locale is not in all-locales", () => {
+ assert.ok(!RemoteL10n.isLocaleSupported("und"));
+ });
+ });
+ describe("#formatLocalizableText", () => {
+ let instance;
+ let formatValueStub;
+ beforeEach(() => {
+ instance = new _RemoteL10n();
+ formatValueStub = sandbox.stub();
+ sandbox
+ .stub(instance, "l10n")
+ .get(() => ({ formatValue: formatValueStub }));
+ });
+ it("should localize a string_id", async () => {
+ formatValueStub.resolves("VALUE");
+
+ assert.equal(
+ await instance.formatLocalizableText({ string_id: "ID" }),
+ "VALUE"
+ );
+ assert.calledOnce(formatValueStub);
+ });
+ it("should pass through a string", async () => {
+ formatValueStub.reset();
+
+ assert.equal(
+ await instance.formatLocalizableText("unchanged"),
+ "unchanged"
+ );
+ assert.isFalse(formatValueStub.called);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/RichText.test.jsx b/browser/components/newtab/test/unit/asrouter/RichText.test.jsx
new file mode 100644
index 0000000000..07c2a4d4be
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/RichText.test.jsx
@@ -0,0 +1,101 @@
+import {
+ convertLinks,
+ RichText,
+} from "content-src/asrouter/components/RichText/RichText";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+import {
+ Localized,
+ LocalizationProvider,
+ ReactLocalization,
+} from "@fluent/react";
+import { mount } from "enzyme";
+import React from "react";
+
+function mockL10nWrapper(content) {
+ const bundle = new FluentBundle("en-US");
+ for (const [id, value] of Object.entries(content)) {
+ if (typeof value === "string") {
+ bundle.addResource(new FluentResource(`${id} = ${value}`));
+ }
+ }
+ const l10n = new ReactLocalization([bundle]);
+ return {
+ wrappingComponent: LocalizationProvider,
+ wrappingComponentProps: { l10n },
+ };
+}
+
+describe("convertLinks", () => {
+ let sandbox;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should return an object with anchor elements", () => {
+ const cta = {
+ url: "https://foo.com",
+ metric: "foo",
+ };
+ const stub = sandbox.stub();
+ const result = convertLinks({ cta }, stub);
+
+ assert.property(result, "cta");
+ assert.propertyVal(result.cta, "type", "a");
+ assert.propertyVal(result.cta.props, "href", cta.url);
+ assert.propertyVal(result.cta.props, "data-metric", cta.metric);
+ assert.propertyVal(result.cta.props, "onClick", stub);
+ });
+ it("should return an anchor element without href", () => {
+ const cta = {
+ url: "https://foo.com",
+ metric: "foo",
+ action: "OPEN_MENU",
+ args: "appMenu",
+ entrypoint_name: "entrypoint_name",
+ entrypoint_value: "entrypoint_value",
+ };
+ const stub = sandbox.stub();
+ const result = convertLinks({ cta }, stub);
+
+ assert.property(result, "cta");
+ assert.propertyVal(result.cta, "type", "a");
+ assert.propertyVal(result.cta.props, "href", false);
+ assert.propertyVal(result.cta.props, "data-metric", cta.metric);
+ assert.propertyVal(result.cta.props, "data-action", cta.action);
+ assert.propertyVal(result.cta.props, "data-args", cta.args);
+ assert.propertyVal(
+ result.cta.props,
+ "data-entrypoint_name",
+ cta.entrypoint_name
+ );
+ assert.propertyVal(
+ result.cta.props,
+ "data-entrypoint_value",
+ cta.entrypoint_value
+ );
+ assert.propertyVal(result.cta.props, "onClick", stub);
+ });
+ it("should follow openNewWindow prop", () => {
+ const cta = { url: "https://foo.com" };
+ const newWindow = convertLinks({ cta }, sandbox.stub(), false, true);
+ const sameWindow = convertLinks({ cta }, sandbox.stub(), false);
+
+ assert.propertyVal(newWindow.cta.props, "target", "_blank");
+ assert.propertyVal(sameWindow.cta.props, "target", "");
+ });
+ it("should allow for custom elements & styles", () => {
+ const wrapper = mount(
+ <RichText
+ customElements={{ em: <em style={{ color: "#f05" }} /> }}
+ text="<em>foo</em>"
+ localization_id="text"
+ />,
+ mockL10nWrapper({ text: "<em>foo</em>" })
+ );
+
+ const localized = wrapper.find(Localized);
+ assert.propertyVal(localized.props().elems.em.props.style, "color", "#f05");
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js b/browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js
new file mode 100644
index 0000000000..fc8fbe15ac
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js
@@ -0,0 +1,43 @@
+import EOYSnippetSchema from "../../../content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json";
+import SimpleBelowSearchSnippetSchema from "../../../content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json";
+import SimpleSnippetSchema from "../../../content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json";
+import { SnippetsTestMessageProvider } from "../../../lib/SnippetsTestMessageProvider.sys.mjs";
+import SubmitFormSnippetSchema from "../../../content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json";
+import SubmitFormScene2SnippetSchema from "../../../content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json";
+
+const schemas = {
+ simple_snippet: SimpleSnippetSchema,
+ newsletter_snippet: SubmitFormSnippetSchema,
+ fxa_signup_snippet: SubmitFormSnippetSchema,
+ send_to_device_snippet: SubmitFormSnippetSchema,
+ send_to_device_scene2_snippet: SubmitFormScene2SnippetSchema,
+ eoy_snippet: EOYSnippetSchema,
+ simple_below_search_snippet: SimpleBelowSearchSnippetSchema,
+};
+
+describe("SnippetsTestMessageProvider", async () => {
+ let messages = await SnippetsTestMessageProvider.getMessages();
+
+ it("should return an array of messages", () => {
+ assert.isArray(messages);
+ });
+
+ it("should have a valid example of each schema", () => {
+ Object.keys(schemas).forEach(templateName => {
+ const example = messages.find(
+ message => message.template === templateName
+ );
+ assert.ok(example, `has a ${templateName} example`);
+ });
+ });
+
+ it("should have examples that are valid", () => {
+ messages.forEach(example => {
+ assert.jsonSchema(
+ example.content,
+ schemas[example.template],
+ `${example.id} should be valid`
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js b/browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js
new file mode 100644
index 0000000000..eaef468488
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js
@@ -0,0 +1,88 @@
+import { ASRouterTargeting } from "lib/ASRouterTargeting.jsm";
+import docs from "content-src/asrouter/docs/targeting-attributes.md";
+
+// The following targeting parameters are either deprecated or should not be included in the docs for some reason.
+const SKIP_DOCS = [];
+// These are extra message context attributes via ASRouter.jsm
+const MESSAGE_CONTEXT_ATTRIBUTES = ["previousSessionEnd"];
+
+function getHeadingsFromDocs() {
+ const re = /### `(\w+)`/g;
+ const found = [];
+ let match = 1;
+ while (match) {
+ match = re.exec(docs);
+ if (match) {
+ found.push(match[1]);
+ }
+ }
+ return found;
+}
+
+function getTOCFromDocs() {
+ const re = /## Available attributes\n+([^]+)\n+## Detailed usage/;
+ const sectionMatch = docs.match(re);
+ if (!sectionMatch) {
+ return [];
+ }
+ const [, listText] = sectionMatch;
+ const re2 = /\[(\w+)\]/g;
+ const found = [];
+ let match = 1;
+ while (match) {
+ match = re2.exec(listText);
+ if (match) {
+ found.push(match[1]);
+ }
+ }
+ return found;
+}
+
+describe("ASRTargeting docs", () => {
+ const DOCS_TARGETING_HEADINGS = getHeadingsFromDocs();
+ const DOCS_TOC = getTOCFromDocs();
+ const ASRTargetingAttributes = [
+ ...Object.keys(ASRouterTargeting.Environment).filter(
+ attribute => !SKIP_DOCS.includes(attribute)
+ ),
+ ...MESSAGE_CONTEXT_ATTRIBUTES,
+ ];
+
+ describe("All targeting params documented in targeting-attributes.md", () => {
+ for (const targetingParam of ASRTargetingAttributes) {
+ // If this test is failing, you probably forgot to add docs to content-src/asrouter/targeting-attributes.md
+ // for a new targeting attribute, or you forgot to put it in the table of contents up top.
+ it(`should have docs and table of contents entry for ${targetingParam}`, () => {
+ assert.include(
+ DOCS_TARGETING_HEADINGS,
+ targetingParam,
+ `Didn't find the heading: ### \`${targetingParam}\``
+ );
+ assert.include(
+ DOCS_TOC,
+ targetingParam,
+ `Didn't find a table of contents entry for ${targetingParam}`
+ );
+ });
+ }
+ });
+ describe("No extra attributes in targeting-attributes.md", () => {
+ // "allow" includes targeting attributes that are not implemented by
+ // ASRTargetingAttributes. For example trigger context passed to the evaluation
+ // context in when a trigger runs or ASRouter state used in the evaluation.
+ const allow = ["messageImpressions", "screenImpressions"];
+ for (const targetingParam of DOCS_TARGETING_HEADINGS.filter(
+ doc => !allow.includes(doc)
+ )) {
+ // If this test is failing, you might have spelled something wrong or removed a targeting param without
+ // removing its docs.
+ it(`should have an implementation for ${targetingParam} in ASRouterTargeting.Environment`, () => {
+ assert.include(
+ ASRTargetingAttributes,
+ targetingParam,
+ `Didn't find an implementation for ${targetingParam}`
+ );
+ });
+ }
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx b/browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx
new file mode 100644
index 0000000000..b581886111
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx
@@ -0,0 +1,516 @@
+import { ASRouterUISurface } from "content-src/asrouter/asrouter-content";
+import { ASRouterUtils } from "content-src/asrouter/asrouter-utils";
+import { GlobalOverrider } from "test/unit/utils";
+import { FAKE_LOCAL_MESSAGES } from "./constants";
+import React from "react";
+import { mount } from "enzyme";
+
+let [FAKE_MESSAGE] = FAKE_LOCAL_MESSAGES;
+const FAKE_NEWSLETTER_SNIPPET = FAKE_LOCAL_MESSAGES.find(
+ msg => msg.id === "newsletter"
+);
+const FAKE_FXA_SNIPPET = FAKE_LOCAL_MESSAGES.find(msg => msg.id === "fxa");
+const FAKE_BELOW_SEARCH_SNIPPET = FAKE_LOCAL_MESSAGES.find(
+ msg => msg.id === "belowsearch"
+);
+
+FAKE_MESSAGE = Object.assign({}, FAKE_MESSAGE, { provider: "fakeprovider" });
+
+describe("ASRouterUtils", () => {
+ let globalOverrider;
+ let sandbox;
+ let globals;
+ beforeEach(() => {
+ globalOverrider = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ globals = {
+ ASRouterMessage: sandbox.stub(),
+ };
+ globalOverrider.set(globals);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globalOverrider.restore();
+ });
+ it("should send a message with the right payload data", () => {
+ ASRouterUtils.sendTelemetry({ id: 1, event: "CLICK" });
+
+ assert.calledOnce(globals.ASRouterMessage);
+ assert.calledWith(globals.ASRouterMessage, {
+ type: "AS_ROUTER_TELEMETRY_USER_EVENT",
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ data: {
+ id: 1,
+ event: "CLICK",
+ },
+ });
+ });
+});
+
+describe("ASRouterUISurface", () => {
+ let wrapper;
+ let globalOverrider;
+ let sandbox;
+ let headerPortal;
+ let footerPortal;
+ let root;
+ let fakeDocument;
+ let globals;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ headerPortal = document.createElement("div");
+ footerPortal = document.createElement("div");
+ root = document.createElement("div");
+ sandbox.stub(footerPortal, "querySelector").returns(footerPortal);
+ fakeDocument = {
+ location: { href: "" },
+ _listeners: new Set(),
+ _visibilityState: "hidden",
+ head: {
+ appendChild(el) {
+ return el;
+ },
+ },
+ get visibilityState() {
+ return this._visibilityState;
+ },
+ set visibilityState(value) {
+ if (this._visibilityState === value) {
+ return;
+ }
+ this._visibilityState = value;
+ this._listeners.forEach(l => l());
+ },
+ addEventListener(event, listener) {
+ this._listeners.add(listener);
+ },
+ removeEventListener(event, listener) {
+ this._listeners.delete(listener);
+ },
+ get body() {
+ return document.createElement("body");
+ },
+ getElementById(id) {
+ switch (id) {
+ case "header-asrouter-container":
+ return headerPortal;
+ case "root":
+ return root;
+ default:
+ return footerPortal;
+ }
+ },
+ createElement(tag) {
+ return document.createElement(tag);
+ },
+ };
+ globals = {
+ ASRouterMessage: sandbox.stub().resolves(),
+ ASRouterAddParentListener: sandbox.stub(),
+ ASRouterRemoveParentListener: sandbox.stub(),
+ fetch: sandbox.stub().resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({}),
+ }),
+ };
+ globalOverrider = new GlobalOverrider();
+ globalOverrider.set(globals);
+ sandbox.stub(ASRouterUtils, "sendTelemetry");
+
+ wrapper = mount(<ASRouterUISurface document={fakeDocument} />);
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globalOverrider.restore();
+ });
+
+ it("should render the component if a message id is defined", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+ assert.isTrue(wrapper.exists());
+ });
+
+ it("should pass in the correct form_method for newsletter snippets", () => {
+ wrapper.setState({ message: FAKE_NEWSLETTER_SNIPPET });
+
+ assert.isTrue(wrapper.find("SubmitFormSnippet").exists());
+ assert.propertyVal(
+ wrapper.find("SubmitFormSnippet").props(),
+ "form_method",
+ "POST"
+ );
+ });
+
+ it("should pass in the correct form_method for fxa snippets", () => {
+ wrapper.setState({ message: FAKE_FXA_SNIPPET });
+
+ assert.isTrue(wrapper.find("SubmitFormSnippet").exists());
+ assert.propertyVal(
+ wrapper.find("SubmitFormSnippet").props(),
+ "form_method",
+ "GET"
+ );
+ });
+
+ it("should render a preview banner if message provider is preview", () => {
+ wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } });
+ assert.isTrue(wrapper.find(".snippets-preview-banner").exists());
+ });
+
+ it("should not render a preview banner if message provider is not preview", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+ assert.isFalse(wrapper.find(".snippets-preview-banner").exists());
+ });
+
+ it("should render a SimpleSnippet in the footer portal", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+ assert.isTrue(footerPortal.childElementCount > 0);
+ assert.equal(headerPortal.childElementCount, 0);
+ });
+
+ it("should not render a SimpleBelowSearchSnippet in a portal", () => {
+ wrapper.setState({ message: FAKE_BELOW_SEARCH_SNIPPET });
+ assert.equal(headerPortal.childElementCount, 0);
+ assert.equal(footerPortal.childElementCount, 0);
+ });
+
+ it("should dispatch an event to select the correct theme", () => {
+ const stub = sandbox.stub(window, "dispatchEvent");
+ sandbox
+ .stub(ASRouterUtils, "getPreviewEndpoint")
+ .returns({ theme: "dark" });
+
+ wrapper = mount(<ASRouterUISurface document={fakeDocument} />);
+
+ assert.calledOnce(stub);
+ assert.property(stub.firstCall.args[0].detail.data, "ntp_background");
+ assert.property(stub.firstCall.args[0].detail.data, "ntp_text");
+ assert.property(stub.firstCall.args[0].detail.data, "sidebar");
+ assert.property(stub.firstCall.args[0].detail.data, "sidebar_text");
+ });
+
+ it("should set `dir=rtl` on the page's <html> element if the dir param is set", () => {
+ assert.notPropertyVal(fakeDocument, "dir", "rtl");
+ sandbox.stub(ASRouterUtils, "getPreviewEndpoint").returns({ dir: "rtl" });
+
+ wrapper = mount(<ASRouterUISurface document={fakeDocument} />);
+ assert.propertyVal(fakeDocument, "dir", "rtl");
+ });
+
+ describe("snippets", () => {
+ it("should send correct event and source when snippet is blocked", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+
+ wrapper.find(".blockButton").simulate("click");
+ assert.propertyVal(
+ ASRouterUtils.sendTelemetry.firstCall.args[0],
+ "event",
+ "BLOCK"
+ );
+ assert.propertyVal(
+ ASRouterUtils.sendTelemetry.firstCall.args[0],
+ "source",
+ "NEWTAB_FOOTER_BAR"
+ );
+ });
+
+ it("should not send telemetry when a preview snippet is blocked", () => {
+ wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } });
+
+ wrapper.find(".blockButton").simulate("click");
+ assert.notCalled(ASRouterUtils.sendTelemetry);
+ });
+ });
+
+ describe("impressions", () => {
+ function simulateVisibilityChange(value) {
+ fakeDocument.visibilityState = value;
+ }
+
+ it("should call blockById after CTA link is clicked", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+ sandbox.stub(ASRouterUtils, "blockById").resolves();
+ wrapper.instance().sendClick({ target: { dataset: { metric: "" } } });
+
+ assert.calledOnce(ASRouterUtils.blockById);
+ assert.calledWith(ASRouterUtils.blockById, FAKE_MESSAGE.id);
+ });
+
+ it("should executeAction if defined on the anchor", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+ sandbox.spy(ASRouterUtils, "executeAction");
+ wrapper.instance().sendClick({
+ target: { dataset: { action: "OPEN_MENU", args: "appMenu" } },
+ });
+
+ assert.calledOnce(ASRouterUtils.executeAction);
+ assert.calledWithExactly(ASRouterUtils.executeAction, {
+ type: "OPEN_MENU",
+ data: { args: "appMenu" },
+ });
+ });
+
+ it("should not call blockById if do_not_autoblock is true", () => {
+ wrapper.setState({
+ message: {
+ ...FAKE_MESSAGE,
+ ...{ content: { ...FAKE_MESSAGE.content, do_not_autoblock: true } },
+ },
+ });
+ sandbox.stub(ASRouterUtils, "blockById");
+ wrapper.instance().sendClick({ target: { dataset: { metric: "" } } });
+
+ assert.notCalled(ASRouterUtils.blockById);
+ });
+
+ it("should not send an impression if no message exists", () => {
+ simulateVisibilityChange("visible");
+
+ assert.notCalled(ASRouterUtils.sendTelemetry);
+ });
+
+ it("should not send an impression if the page is not visible", () => {
+ simulateVisibilityChange("hidden");
+ wrapper.setState({ message: FAKE_MESSAGE });
+
+ assert.notCalled(ASRouterUtils.sendTelemetry);
+ });
+
+ it("should not send an impression for a preview message", () => {
+ wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } });
+ assert.notCalled(ASRouterUtils.sendTelemetry);
+
+ simulateVisibilityChange("visible");
+ assert.notCalled(ASRouterUtils.sendTelemetry);
+ });
+
+ it("should send an impression ping when there is a message and the page becomes visible", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+ assert.notCalled(ASRouterUtils.sendTelemetry);
+
+ simulateVisibilityChange("visible");
+ assert.calledOnce(ASRouterUtils.sendTelemetry);
+ });
+
+ it("should send the correct impression source", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+ simulateVisibilityChange("visible");
+
+ assert.calledOnce(ASRouterUtils.sendTelemetry);
+ assert.propertyVal(
+ ASRouterUtils.sendTelemetry.firstCall.args[0],
+ "event",
+ "IMPRESSION"
+ );
+ assert.propertyVal(
+ ASRouterUtils.sendTelemetry.firstCall.args[0],
+ "source",
+ "NEWTAB_FOOTER_BAR"
+ );
+ });
+
+ it("should send an impression ping when the page is visible and a message gets loaded", () => {
+ simulateVisibilityChange("visible");
+ wrapper.setState({ message: {} });
+ assert.notCalled(ASRouterUtils.sendTelemetry);
+
+ wrapper.setState({ message: FAKE_MESSAGE });
+ assert.calledOnce(ASRouterUtils.sendTelemetry);
+ });
+
+ it("should send another impression ping if the message id changes", () => {
+ simulateVisibilityChange("visible");
+ wrapper.setState({ message: FAKE_MESSAGE });
+ assert.calledOnce(ASRouterUtils.sendTelemetry);
+
+ wrapper.setState({ message: FAKE_LOCAL_MESSAGES[1] });
+ assert.calledTwice(ASRouterUtils.sendTelemetry);
+ });
+
+ it("should not send another impression ping if the message id has not changed", () => {
+ simulateVisibilityChange("visible");
+ wrapper.setState({ message: FAKE_MESSAGE });
+ assert.calledOnce(ASRouterUtils.sendTelemetry);
+
+ wrapper.setState({ somethingElse: 123 });
+ assert.calledOnce(ASRouterUtils.sendTelemetry);
+ });
+
+ it("should not send another impression ping if the message is cleared", () => {
+ simulateVisibilityChange("visible");
+ wrapper.setState({ message: FAKE_MESSAGE });
+ assert.calledOnce(ASRouterUtils.sendTelemetry);
+
+ wrapper.setState({ message: {} });
+ assert.calledOnce(ASRouterUtils.sendTelemetry);
+ });
+
+ it("should call .sendTelemetry with the right message data", () => {
+ simulateVisibilityChange("visible");
+ wrapper.setState({ message: FAKE_MESSAGE });
+
+ assert.calledOnce(ASRouterUtils.sendTelemetry);
+ const [payload] = ASRouterUtils.sendTelemetry.firstCall.args;
+
+ assert.propertyVal(payload, "message_id", FAKE_MESSAGE.id);
+ assert.propertyVal(payload, "event", "IMPRESSION");
+ assert.propertyVal(
+ payload,
+ "action",
+ `${FAKE_MESSAGE.provider}_user_event`
+ );
+ assert.propertyVal(payload, "source", "NEWTAB_FOOTER_BAR");
+ });
+
+ it("should construct a OPEN_ABOUT_PAGE action with attribution", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+ const stub = sandbox.stub(ASRouterUtils, "executeAction");
+
+ wrapper.instance().sendClick({
+ target: {
+ dataset: {
+ metric: "",
+ entrypoint_value: "snippet",
+ action: "OPEN_PREFERENCES_PAGE",
+ args: "home",
+ },
+ },
+ });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: { args: "home", entrypoint: "snippet" },
+ });
+ });
+
+ it("should construct a OPEN_ABOUT_PAGE action with attribution", () => {
+ wrapper.setState({ message: FAKE_MESSAGE });
+ const stub = sandbox.stub(ASRouterUtils, "executeAction");
+
+ wrapper.instance().sendClick({
+ target: {
+ dataset: {
+ metric: "",
+ entrypoint_name: "entryPoint",
+ entrypoint_value: "snippet",
+ action: "OPEN_ABOUT_PAGE",
+ args: "logins",
+ },
+ },
+ });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, {
+ type: "OPEN_ABOUT_PAGE",
+ data: { args: "logins", entrypoint: "entryPoint=snippet" },
+ });
+ });
+ });
+
+ describe(".fetchFlowParams", () => {
+ let dispatchStub;
+ const assertCalledWithURL = url =>
+ assert.calledWith(globals.fetch, new URL(url).toString(), {
+ credentials: "omit",
+ });
+ beforeEach(() => {
+ dispatchStub = sandbox.stub();
+ wrapper = mount(
+ <ASRouterUISurface
+ dispatch={dispatchStub}
+ fxaEndpoint="https://accounts.firefox.com"
+ />
+ );
+ });
+ it("should use the base url returned from the endpoint pref", async () => {
+ wrapper = mount(
+ <ASRouterUISurface
+ dispatch={dispatchStub}
+ fxaEndpoint="https://foo.com"
+ />
+ );
+ await wrapper.instance().fetchFlowParams();
+
+ assertCalledWithURL("https://foo.com/metrics-flow");
+ });
+ it("should add given search params to the URL", async () => {
+ const params = { foo: "1", bar: "2" };
+
+ await wrapper.instance().fetchFlowParams(params);
+
+ assertCalledWithURL(
+ "https://accounts.firefox.com/metrics-flow?foo=1&bar=2"
+ );
+ });
+ it("should return flowId, flowBeginTime, deviceId on a 200 response", async () => {
+ const flowInfo = { flowId: "foo", flowBeginTime: 123, deviceId: "bar" };
+ globals.fetch
+ .withArgs("https://accounts.firefox.com/metrics-flow")
+ .resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(flowInfo),
+ });
+
+ const result = await wrapper.instance().fetchFlowParams();
+ assert.deepEqual(result, flowInfo);
+ });
+
+ describe(".onUserAction", () => {
+ it("if the action.type is ENABLE_FIREFOX_MONITOR, it should generate the right monitor URL given some flowParams", async () => {
+ const flowInfo = { flowId: "foo", flowBeginTime: 123, deviceId: "bar" };
+ globals.fetch
+ .withArgs(
+ "https://accounts.firefox.com/metrics-flow?utm_term=avocado"
+ )
+ .resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(flowInfo),
+ });
+
+ sandbox.spy(ASRouterUtils, "executeAction");
+
+ const msg = {
+ type: "ENABLE_FIREFOX_MONITOR",
+ data: {
+ args: {
+ url: "https://monitor.firefox.com?foo=bar",
+ flowRequestParams: {
+ utm_term: "avocado",
+ },
+ },
+ },
+ };
+
+ await wrapper.instance().onUserAction(msg);
+
+ assertCalledWithURL(
+ "https://accounts.firefox.com/metrics-flow?utm_term=avocado"
+ );
+ assert.calledWith(ASRouterUtils.executeAction, {
+ type: "OPEN_URL",
+ data: {
+ args: new URL(
+ "https://monitor.firefox.com?foo=bar&deviceId=bar&flowId=foo&flowBeginTime=123"
+ ).toString(),
+ },
+ });
+ });
+ it("if the action.type is not ENABLE_FIREFOX_MONITOR, it should just call ASRouterUtils.executeAction", async () => {
+ const msg = {
+ type: "FOO",
+ data: {
+ args: "bar",
+ },
+ };
+ sandbox.spy(ASRouterUtils, "executeAction");
+ await wrapper.instance().onUserAction(msg);
+ assert.calledWith(ASRouterUtils.executeAction, msg);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js b/browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js
new file mode 100644
index 0000000000..1027b8ddab
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js
@@ -0,0 +1,100 @@
+import { ASRouterUtils } from "content-src/asrouter/asrouter-utils";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("ASRouterUtils", () => {
+ let globals = null;
+ let overrider = null;
+ let sandbox = null;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = {
+ ASRouterMessage: sandbox.stub().resolves({}),
+ };
+ overrider = new GlobalOverrider();
+ overrider.set(globals);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ overrider.restore();
+ });
+ describe("sendMessage", () => {
+ it("default", () => {
+ ASRouterUtils.sendMessage({ foo: "bar" });
+ assert.calledOnce(globals.ASRouterMessage);
+ assert.calledWith(globals.ASRouterMessage, { foo: "bar" });
+ });
+ it("throws if ASRouterMessage is not defined", () => {
+ overrider.set("ASRouterMessage", undefined);
+ assert.throws(() => ASRouterUtils.sendMessage({ foo: "bar" }));
+ });
+ });
+ describe("blockById", () => {
+ it("default", () => {
+ ASRouterUtils.blockById(1, { foo: "bar" });
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { foo: "bar", id: 1 } })
+ );
+ });
+ });
+ describe("modifyMessageJson", () => {
+ it("default", () => {
+ ASRouterUtils.modifyMessageJson({ foo: "bar" });
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { content: { foo: "bar" } } })
+ );
+ });
+ });
+ describe("executeAction", () => {
+ it("default", () => {
+ ASRouterUtils.executeAction({ foo: "bar" });
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { foo: "bar" } })
+ );
+ });
+ });
+ describe("unblockById", () => {
+ it("default", () => {
+ ASRouterUtils.unblockById(2);
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { id: 2 } })
+ );
+ });
+ });
+ describe("blockBundle", () => {
+ it("default", () => {
+ ASRouterUtils.blockBundle(2);
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { bundle: 2 } })
+ );
+ });
+ });
+ describe("unblockBundle", () => {
+ it("default", () => {
+ ASRouterUtils.unblockBundle(2);
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { bundle: 2 } })
+ );
+ });
+ });
+ describe("overrideMessage", () => {
+ it("default", () => {
+ ASRouterUtils.overrideMessage(12);
+ assert.calledWith(
+ globals.ASRouterMessage,
+ sinon.match({ data: { id: 12 } })
+ );
+ });
+ });
+ describe("sendTelemetry", () => {
+ it("default", () => {
+ ASRouterUtils.sendTelemetry({ foo: "bar" });
+ assert.calledOnce(globals.ASRouterMessage);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js b/browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js
new file mode 100644
index 0000000000..335318d9c6
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js
@@ -0,0 +1,26 @@
+import EOYSnippetSchema from "content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json";
+import { expectedValues } from "./snippets-fx57";
+import SimpleSnippetSchema from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json";
+import SubmitFormSchema from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json";
+
+export const SnippetsSchemas = {
+ eoy_snippet: EOYSnippetSchema,
+ simple_snippet: SimpleSnippetSchema,
+ newsletter_snippet: SubmitFormSchema,
+ fxa_signup_snippet: SubmitFormSchema,
+ send_to_device_snippet: SubmitFormSchema,
+};
+
+describe("Firefox 57 compatibility test", () => {
+ Object.keys(expectedValues).forEach(template => {
+ describe(template, () => {
+ const schema = SnippetsSchemas[template];
+ it(`should have a schema for ${template}`, () => {
+ assert.ok(schema);
+ });
+ it(`should validate with the schema for ${template}`, () => {
+ assert.jsonSchema(expectedValues[template], schema);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js b/browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js
new file mode 100644
index 0000000000..e63256315b
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js
@@ -0,0 +1,125 @@
+/**
+ * IMPORTANT NOTE!!!
+ *
+ * Please DO NOT introduce breaking changes file without contacting snippets endpoint engineers
+ * and changing the schema version to reflect a breaking change.
+ *
+ */
+
+const DATA_URI_IMAGE =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+
+export const expectedValues = {
+ // Simple Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/simple-snippet.html)
+ simple_snippet: {
+ icon: DATA_URI_IMAGE,
+ button_label: "Click me",
+ button_url: "https://mozilla.org",
+ button_background_color: "#FF0000",
+ button_color: "#FFFFFF",
+ text: "Hello world",
+ title: "Hi!",
+ title_icon: DATA_URI_IMAGE,
+ tall: true,
+ },
+
+ // FXA Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/fxa.html)
+ fxa_signup_snippet: {
+ scene1_icon: DATA_URI_IMAGE,
+ scene1_button_label: "Click me",
+ scene1_button_background_color: "#FF0000",
+ scene1_button_color: "#FFFFFF",
+ scene1_text: "Hello <em>world</em>",
+ scene1_title: "Hi!",
+ scene1_title_icon: DATA_URI_IMAGE,
+
+ scene2_text: "Second scene",
+ scene2_title: "Second scene title",
+ scene2_email_placeholder_text: "Email here",
+ scene2_button_label: "Sign Me Up",
+ scene2_dismiss_button_text: "Dismiss",
+
+ utm_campaign: "snippets123",
+ utm_term: "123term",
+ },
+
+ // Send To Device Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/send-to-device.html)
+ send_to_device_snippet: {
+ include_sms: true,
+ locale: "de",
+ country: "DE",
+ message_id_sms: "foo",
+ message_id_email: "foo",
+ scene1_button_background_color: "#FF0000",
+ scene1_button_color: "#FFFFFF",
+ scene1_button_label: "Click me",
+ scene1_icon: DATA_URI_IMAGE,
+ scene1_text: "Hello world",
+ scene1_title: "Hi!",
+ scene1_title_icon: DATA_URI_IMAGE,
+
+ scene2_button_label: "Sign Me Up",
+ scene2_disclaimer_html: "Hello <em>world</em>",
+ scene2_dismiss_button_text: "Dismiss",
+ scene2_icon: DATA_URI_IMAGE,
+ scene2_input_placeholder: "Email here",
+
+ scene2_text: "Second scene",
+ scene2_title: "Second scene title",
+
+ error_text: "error",
+ success_text: "all good",
+ success_title: "Ok!",
+ },
+
+ // Newsletter Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/newsletter-subscribe.html)
+ newsletter_snippet: {
+ scene1_icon: DATA_URI_IMAGE,
+ scene1_button_label: "Click me",
+ scene1_button_background_color: "#FF0000",
+ scene1_button_color: "#FFFFFF",
+ scene1_text: "Hello world",
+ scene1_title: "Hi!",
+ scene1_title_icon: DATA_URI_IMAGE,
+
+ scene2_text: "Second scene",
+ scene2_title: "Second scene title",
+ scene2_newsletter: "foo",
+ scene2_email_placeholder_text: "Email here",
+ scene2_button_label: "Sign Me Up",
+ scene2_privacy_html: "Hello <em>world</em>",
+ scene2_dismiss_button_text: "Dismiss",
+
+ locale: "de",
+
+ error_text: "error",
+ success_text: "all good",
+ },
+
+ // EOY Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/mofo-eoy-2017.html)
+ eoy_snippet: {
+ block_button_text: "Block",
+
+ donation_form_url: "https://donate.mozilla.org/",
+ text: "Big corporations want to restrict how we access the web. Fake news is making it harder for us to find the truth. Online bullies are silencing inspired voices. The not-for-profit Mozilla Foundation fights for a healthy internet with programs like our Tech Policy Fellowships and Internet Health Report; will you donate today?",
+ icon: DATA_URI_IMAGE,
+ button_label: "Donate",
+ monthly_checkbox_label_text: "Make my donation monthly",
+ button_background_color: "#0060DF",
+ button_color: "#FFFFFF",
+ background_color: "#FFFFFF",
+ text_color: "#000000",
+ highlight_color: "#FFE900",
+
+ locale: "en-US",
+ currency_code: "usd",
+
+ donation_amount_first: 50,
+ donation_amount_second: 25,
+ donation_amount_third: 10,
+ donation_amount_fourth: 3,
+ selected_button: "donation_amount_second",
+
+ test: "bold",
+ },
+};
diff --git a/browser/components/newtab/test/unit/asrouter/constants.js b/browser/components/newtab/test/unit/asrouter/constants.js
new file mode 100644
index 0000000000..392ca66ae3
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/constants.js
@@ -0,0 +1,137 @@
+export const CHILD_TO_PARENT_MESSAGE_NAME = "ASRouter:child-to-parent";
+export const PARENT_TO_CHILD_MESSAGE_NAME = "ASRouter:parent-to-child";
+
+export const FAKE_LOCAL_MESSAGES = [
+ {
+ id: "foo",
+ provider: "snippets",
+ template: "simple_snippet",
+ content: { title: "Foo", body: "Foo123" },
+ },
+ {
+ id: "foo1",
+ template: "simple_snippet",
+ provider: "snippets",
+ bundled: 2,
+ order: 1,
+ content: { title: "Foo1", body: "Foo123-1" },
+ },
+ {
+ id: "foo2",
+ template: "simple_snippet",
+ provider: "snippets",
+ bundled: 2,
+ order: 2,
+ content: { title: "Foo2", body: "Foo123-2" },
+ },
+ {
+ id: "bar",
+ template: "fancy_template",
+ content: { title: "Foo", body: "Foo123" },
+ },
+ { id: "baz", content: { title: "Foo", body: "Foo123" } },
+ {
+ id: "newsletter",
+ provider: "snippets",
+ template: "newsletter_snippet",
+ content: { title: "Foo", body: "Foo123" },
+ },
+ {
+ id: "fxa",
+ provider: "snippets",
+ template: "fxa_signup_snippet",
+ content: { title: "Foo", body: "Foo123" },
+ },
+ {
+ id: "belowsearch",
+ provider: "snippets",
+ template: "simple_below_search_snippet",
+ content: { text: "Foo" },
+ },
+];
+export const FAKE_LOCAL_PROVIDER = {
+ id: "onboarding",
+ type: "local",
+ localProvider: "FAKE_LOCAL_PROVIDER",
+ enabled: true,
+ cohort: 0,
+};
+export const FAKE_LOCAL_PROVIDERS = {
+ FAKE_LOCAL_PROVIDER: {
+ getMessages: () => Promise.resolve(FAKE_LOCAL_MESSAGES),
+ },
+};
+
+export const FAKE_REMOTE_MESSAGES = [
+ {
+ id: "qux",
+ template: "simple_snippet",
+ content: { title: "Qux", body: "hello world" },
+ },
+];
+export const FAKE_REMOTE_PROVIDER = {
+ id: "remotey",
+ type: "remote",
+ url: "http://fake.com/endpoint",
+ enabled: true,
+};
+
+export const FAKE_REMOTE_SETTINGS_PROVIDER = {
+ id: "remotey-settingsy",
+ type: "remote-settings",
+ collection: "collectionname",
+ enabled: true,
+};
+
+const notificationText = new String("Fake notification text"); // eslint-disable-line
+notificationText.attributes = { tooltiptext: "Fake tooltip text" };
+
+export const FAKE_RECOMMENDATION = {
+ id: "fake_id",
+ template: "cfr_doorhanger",
+ content: {
+ category: "cfrDummy",
+ bucket_id: "fake_bucket_id",
+ notification_text: notificationText,
+ info_icon: {
+ label: "Fake Info Icon Label",
+ sumo_path: "a_help_path_fragment",
+ },
+ heading_text: "Fake Heading Text",
+ icon_class: "Fake Icon class",
+ addon: {
+ title: "Fake Addon Title",
+ author: "Fake Addon Author",
+ icon: "a_path_to_some_icon",
+ rating: "4.2",
+ users: "1234",
+ amo_url: "a_path_to_amo",
+ },
+ descriptionDetails: {
+ steps: [{ string_id: "cfr-features-step1" }],
+ },
+ text: "Here is the recommendation text body",
+ buttons: {
+ primary: {
+ label: { string_id: "primary_button_id" },
+ action: {
+ id: "primary_action",
+ data: {},
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "secondary_button_id" },
+ action: { id: "secondary_action" },
+ },
+ {
+ label: { string_id: "secondary_button_id_2" },
+ },
+ {
+ label: { string_id: "secondary_button_id_3" },
+ action: { id: "secondary_action" },
+ },
+ ],
+ },
+ },
+};
diff --git a/browser/components/newtab/test/unit/asrouter/template-utils.test.js b/browser/components/newtab/test/unit/asrouter/template-utils.test.js
new file mode 100644
index 0000000000..e5f4b5ef4d
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/template-utils.test.js
@@ -0,0 +1,31 @@
+import { safeURI } from "content-src/asrouter/template-utils";
+
+describe("safeURI", () => {
+ let warnStub;
+ beforeEach(() => {
+ warnStub = sinon.stub(console, "warn");
+ });
+ afterEach(() => {
+ warnStub.restore();
+ });
+ it("should allow http: URIs", () => {
+ assert.equal(safeURI("http://foo.com"), "http://foo.com");
+ });
+ it("should allow https: URIs", () => {
+ assert.equal(safeURI("https://foo.com"), "https://foo.com");
+ });
+ it("should allow data URIs", () => {
+ assert.equal(
+ safeURI("data:image/png;base64,iVBO"),
+ "data:image/png;base64,iVBO"
+ );
+ });
+ it("should not allow javascript: URIs", () => {
+ assert.equal(safeURI("javascript:foo()"), ""); // eslint-disable-line no-script-url
+ assert.calledOnce(warnStub);
+ });
+ it("should not warn if the URL is falsey ", () => {
+ assert.equal(safeURI(), "");
+ assert.notCalled(warnStub);
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx
new file mode 100644
index 0000000000..bd4ab00468
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx
@@ -0,0 +1,213 @@
+import { EOYSnippet } from "content-src/asrouter/templates/EOYSnippet/EOYSnippet";
+import { GlobalOverrider } from "test/unit/utils";
+import { mount } from "enzyme";
+import React from "react";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+import { LocalizationProvider, ReactLocalization } from "@fluent/react";
+import schema from "content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json";
+
+const DEFAULT_CONTENT = {
+ text: "foo",
+ donation_amount_first: 50,
+ donation_amount_second: 25,
+ donation_amount_third: 10,
+ donation_amount_fourth: 5,
+ donation_form_url: "https://submit.form",
+ button_label: "Donate",
+};
+
+describe("EOYSnippet", () => {
+ let sandbox;
+ let wrapper;
+
+ function mockL10nWrapper(content) {
+ const bundle = new FluentBundle("en-US");
+ for (const [id, value] of Object.entries(content)) {
+ if (typeof value === "string") {
+ bundle.addResource(new FluentResource(`${id} = ${value}`));
+ }
+ }
+ const l10n = new ReactLocalization([bundle]);
+ return {
+ wrappingComponent: LocalizationProvider,
+ wrappingComponentProps: { l10n },
+ };
+ }
+
+ /**
+ * mountAndCheckProps - Mounts a EOYSnippet with DEFAULT_CONTENT extended with any props
+ * passed in the content param and validates props against the schema.
+ * @param {obj} content Object containing custom message content (e.g. {text, icon, title})
+ * @returns enzyme wrapper for EOYSnippet
+ */
+ function mountAndCheckProps(content = {}, provider = "test-provider") {
+ const props = {
+ content: Object.assign({}, DEFAULT_CONTENT, content),
+ provider,
+ onAction: sandbox.stub(),
+ onBlock: sandbox.stub(),
+ sendClick: sandbox.stub(),
+ };
+ const comp = mount(
+ <EOYSnippet {...props} />,
+ mockL10nWrapper(props.content)
+ );
+ // Check schema with the final props the component receives (including defaults)
+ assert.jsonSchema(comp.children().get(0).props.content, schema);
+ return comp;
+ }
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ wrapper = mountAndCheckProps();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should have the correct defaults", () => {
+ wrapper = mountAndCheckProps();
+ // SendToDeviceSnippet is a wrapper around SubmitFormSnippet
+ const { props } = wrapper.children().get(0);
+
+ const defaultProperties = Object.keys(schema.properties).filter(
+ prop => schema.properties[prop].default
+ );
+ assert.lengthOf(defaultProperties, 4);
+ defaultProperties.forEach(prop =>
+ assert.propertyVal(props.content, prop, schema.properties[prop].default)
+ );
+ });
+
+ it("should render 4 donation options", () => {
+ assert.lengthOf(wrapper.find("input[type='radio']"), 4);
+ });
+
+ it("should have a data-metric field", () => {
+ assert.ok(wrapper.find("form[data-metric='EOYSnippetForm']").exists());
+ });
+
+ it("should select the second donation option", () => {
+ wrapper = mountAndCheckProps({ selected_button: "donation_amount_second" });
+
+ assert.propertyVal(
+ wrapper.find("input[type='radio']").get(1).props,
+ "defaultChecked",
+ true
+ );
+ });
+
+ it("should set frequency value to monthly", () => {
+ const form = wrapper.find("form").instance();
+ assert.equal(form.querySelector("[name='frequency']").value, "single");
+
+ form.querySelector("#monthly-checkbox").checked = true;
+ wrapper.find("form").simulate("submit");
+
+ assert.equal(form.querySelector("[name='frequency']").value, "monthly");
+ });
+
+ it("should block after submitting the form", () => {
+ const onBlockStub = sandbox.stub();
+ wrapper.setProps({ onBlock: onBlockStub });
+
+ wrapper.find("form").simulate("submit");
+
+ assert.calledOnce(onBlockStub);
+ });
+
+ it("should not block if do_not_autoblock is true", () => {
+ const onBlockStub = sandbox.stub();
+ wrapper = mountAndCheckProps({ do_not_autoblock: true });
+ wrapper.setProps({ onBlock: onBlockStub });
+
+ wrapper.find("form").simulate("submit");
+
+ assert.notCalled(onBlockStub);
+ });
+
+ it("should report form submissions", () => {
+ wrapper = mountAndCheckProps();
+ const { sendClick } = wrapper.props();
+
+ wrapper.find("form").simulate("submit");
+
+ assert.calledOnce(sendClick);
+ assert.equal(
+ sendClick.firstCall.args[0].target.dataset.metric,
+ "EOYSnippetForm"
+ );
+ });
+
+ it("it should preserve URL GET params as hidden inputs", () => {
+ wrapper = mountAndCheckProps({
+ donation_form_url:
+ "https://donate.mozilla.org/pl/?utm_source=desktop-snippet&amp;utm_medium=snippet&amp;utm_campaign=donate&amp;utm_term=7556",
+ });
+
+ const hiddenInputs = wrapper.find("input[type='hidden']");
+
+ assert.propertyVal(
+ hiddenInputs.find("[name='utm_source']").props(),
+ "value",
+ "desktop-snippet"
+ );
+ assert.propertyVal(
+ hiddenInputs.find("[name='amp;utm_medium']").props(),
+ "value",
+ "snippet"
+ );
+ assert.propertyVal(
+ hiddenInputs.find("[name='amp;utm_campaign']").props(),
+ "value",
+ "donate"
+ );
+ assert.propertyVal(
+ hiddenInputs.find("[name='amp;utm_term']").props(),
+ "value",
+ "7556"
+ );
+ });
+
+ describe("locale", () => {
+ let stub;
+ let globals;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ stub = sandbox.stub().returns({ format: () => {} });
+
+ globals = new GlobalOverrider();
+ globals.set({ Intl: { NumberFormat: stub } });
+ });
+ afterEach(() => {
+ globals.restore();
+ });
+
+ it("should use content.locale for Intl", () => {
+ // triggers component rendering and calls the function we're testing
+ wrapper.setProps({
+ content: {
+ locale: "locale-foo",
+ donation_form_url: DEFAULT_CONTENT.donation_form_url,
+ },
+ });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, "locale-foo", sinon.match.object);
+ });
+
+ it("should use navigator.language as locale fallback", () => {
+ // triggers component rendering and calls the function we're testing
+ wrapper.setProps({
+ content: {
+ locale: null,
+ donation_form_url: DEFAULT_CONTENT.donation_form_url,
+ },
+ });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, navigator.language, sinon.match.object);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx
new file mode 100644
index 0000000000..bef14c6982
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx
@@ -0,0 +1,112 @@
+import { CFRMessageProvider } from "lib/CFRMessageProvider.sys.mjs";
+import CFRDoorhangerSchema from "content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json";
+import CFRChicletSchema from "content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json";
+import InfoBarSchema from "content-src/asrouter/templates/CFR/templates/InfoBar.schema.json";
+
+const SCHEMAS = {
+ cfr_urlbar_chiclet: CFRChicletSchema,
+ cfr_doorhanger: CFRDoorhangerSchema,
+ milestone_message: CFRDoorhangerSchema,
+ infobar: InfoBarSchema,
+};
+
+const DEFAULT_CONTENT = {
+ layout: "addon_recommendation",
+ category: "dummyCategory",
+ bucket_id: "some_bucket_id",
+ notification_text: "Recommendation",
+ heading_text: "Recommended Extension",
+ info_icon: {
+ label: { attributes: { tooltiptext: "Why am I seeing this" } },
+ sumo_path: "extensionrecommendations",
+ },
+ addon: {
+ id: "1234",
+ title: "Addon name",
+ icon: "https://mozilla.org/icon",
+ author: "Author name",
+ amo_url: "https://example.com",
+ },
+ text: "Description of addon",
+ buttons: {
+ primary: {
+ label: {
+ value: "Add Now",
+ attributes: { accesskey: "A" },
+ },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: "https://example.com" },
+ },
+ },
+ secondary: [
+ {
+ label: {
+ value: "Not Now",
+ attributes: { accesskey: "N" },
+ },
+ action: { type: "CANCEL" },
+ },
+ ],
+ },
+};
+
+const L10N_CONTENT = {
+ layout: "addon_recommendation",
+ category: "dummyL10NCategory",
+ bucket_id: "some_bucket_id",
+ notification_text: { string_id: "notification_text_id" },
+ heading_text: { string_id: "heading_text_id" },
+ info_icon: {
+ label: { string_id: "why_seeing_this" },
+ sumo_path: "extensionrecommendations",
+ },
+ addon: {
+ id: "1234",
+ title: "Addon name",
+ icon: "https://mozilla.org/icon",
+ author: "Author name",
+ amo_url: "https://example.com",
+ },
+ text: { string_id: "text_id" },
+ buttons: {
+ primary: {
+ label: { string_id: "btn_ok_id" },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: "https://example.com" },
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "btn_cancel_id" },
+ action: { type: "CANCEL" },
+ },
+ ],
+ },
+};
+
+describe("ExtensionDoorhanger", () => {
+ it("should validate DEFAULT_CONTENT", async () => {
+ const messages = await CFRMessageProvider.getMessages();
+ let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3");
+ assert.ok(doorhangerMessage, "Message found");
+ assert.jsonSchema(
+ { ...doorhangerMessage, content: DEFAULT_CONTENT },
+ CFRDoorhangerSchema
+ );
+ });
+ it("should validate L10N_CONTENT", async () => {
+ const messages = await CFRMessageProvider.getMessages();
+ let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3");
+ assert.ok(doorhangerMessage, "Message found");
+ assert.jsonSchema(
+ { ...doorhangerMessage, content: L10N_CONTENT },
+ CFRDoorhangerSchema
+ );
+ });
+ it("should validate all messages from CFRMessageProvider", async () => {
+ const messages = await CFRMessageProvider.getMessages();
+ messages.forEach(msg => assert.jsonSchema(msg, SCHEMAS[msg.template]));
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx
new file mode 100644
index 0000000000..56828d266b
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx
@@ -0,0 +1,106 @@
+import { FXASignupSnippet } from "content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet";
+import { mount } from "enzyme";
+import React from "react";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+import { LocalizationProvider, ReactLocalization } from "@fluent/react";
+import schema from "content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json";
+import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs";
+
+describe("FXASignupSnippet", () => {
+ let DEFAULT_CONTENT;
+ let sandbox;
+
+ function mockL10nWrapper(content) {
+ const bundle = new FluentBundle("en-US");
+ for (const [id, value] of Object.entries(content)) {
+ if (typeof value === "string") {
+ bundle.addResource(new FluentResource(`${id} = ${value}`));
+ }
+ }
+ const l10n = new ReactLocalization([bundle]);
+ return {
+ wrappingComponent: LocalizationProvider,
+ wrappingComponentProps: { l10n },
+ };
+ }
+
+ function mountAndCheckProps(content = {}) {
+ const props = {
+ id: "foo123",
+ content: Object.assign(
+ { utm_campaign: "foo", utm_term: "bar" },
+ DEFAULT_CONTENT,
+ content
+ ),
+ onBlock() {},
+ onDismiss: sandbox.stub(),
+ sendUserActionTelemetry: sandbox.stub(),
+ onAction: sandbox.stub(),
+ };
+ const comp = mount(
+ <FXASignupSnippet {...props} />,
+ mockL10nWrapper(props.content)
+ );
+ // Check schema with the final props the component receives (including defaults)
+ assert.jsonSchema(comp.children().get(0).props.content, schema);
+ return comp;
+ }
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ DEFAULT_CONTENT = (await SnippetsTestMessageProvider.getMessages()).find(
+ msg => msg.template === "fxa_signup_snippet"
+ ).content;
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should have the correct defaults", () => {
+ const defaults = {
+ id: "foo123",
+ onBlock() {},
+ content: {},
+ onDismiss: sandbox.stub(),
+ sendUserActionTelemetry: sandbox.stub(),
+ onAction: sandbox.stub(),
+ };
+ const wrapper = mount(
+ <FXASignupSnippet {...defaults} />,
+ mockL10nWrapper(DEFAULT_CONTENT)
+ );
+ // FXASignupSnippet is a wrapper around SubmitFormSnippet
+ const { props } = wrapper.children().get(0);
+
+ const defaultProperties = Object.keys(schema.properties).filter(
+ prop => schema.properties[prop].default
+ );
+ assert.lengthOf(defaultProperties, 5);
+ defaultProperties.forEach(prop =>
+ assert.propertyVal(props.content, prop, schema.properties[prop].default)
+ );
+
+ const defaultHiddenProperties = Object.keys(
+ schema.properties.hidden_inputs.properties
+ ).filter(prop => schema.properties.hidden_inputs.properties[prop].default);
+ assert.lengthOf(defaultHiddenProperties, 0);
+ });
+
+ it("should have a form_action", () => {
+ const wrapper = mountAndCheckProps();
+
+ assert.propertyVal(
+ wrapper.children().get(0).props,
+ "form_action",
+ "https://accounts.firefox.com/"
+ );
+ });
+
+ it("should navigate to scene2", () => {
+ const wrapper = mountAndCheckProps({});
+
+ wrapper.find(".ASRouterButton").simulate("click");
+
+ assert.lengthOf(wrapper.find(".mainInput"), 1);
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx
new file mode 100644
index 0000000000..cb80abdae0
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx
@@ -0,0 +1,108 @@
+import { mount } from "enzyme";
+import { NewsletterSnippet } from "content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet";
+import React from "react";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+import { LocalizationProvider, ReactLocalization } from "@fluent/react";
+import schema from "content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json";
+import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs";
+
+describe("NewsletterSnippet", () => {
+ let sandbox;
+ let DEFAULT_CONTENT;
+
+ function mockL10nWrapper(content) {
+ const bundle = new FluentBundle("en-US");
+ for (const [id, value] of Object.entries(content)) {
+ if (typeof value === "string") {
+ bundle.addResource(new FluentResource(`${id} = ${value}`));
+ }
+ }
+ const l10n = new ReactLocalization([bundle]);
+ return {
+ wrappingComponent: LocalizationProvider,
+ wrappingComponentProps: { l10n },
+ };
+ }
+
+ function mountAndCheckProps(content = {}) {
+ const props = {
+ id: "foo123",
+ content: Object.assign({}, DEFAULT_CONTENT, content),
+ onBlock() {},
+ onDismiss: sandbox.stub(),
+ sendUserActionTelemetry: sandbox.stub(),
+ onAction: sandbox.stub(),
+ };
+ const comp = mount(
+ <NewsletterSnippet {...props} />,
+ mockL10nWrapper(props.content)
+ );
+ // Check schema with the final props the component receives (including defaults)
+ assert.jsonSchema(comp.children().get(0).props.content, schema);
+ return comp;
+ }
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ DEFAULT_CONTENT = (await SnippetsTestMessageProvider.getMessages()).find(
+ msg => msg.template === "newsletter_snippet"
+ ).content;
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ describe("schema test", () => {
+ it("should validate the schema and defaults", () => {
+ const wrapper = mountAndCheckProps();
+ wrapper.find(".ASRouterButton").simulate("click");
+ assert.equal(wrapper.find(".mainInput").instance().type, "email");
+ });
+
+ it("should have all of the default fields", () => {
+ const defaults = {
+ id: "foo123",
+ content: {},
+ onBlock() {},
+ onDismiss: sandbox.stub(),
+ sendUserActionTelemetry: sandbox.stub(),
+ onAction: sandbox.stub(),
+ };
+ const wrapper = mount(
+ <NewsletterSnippet {...defaults} />,
+ mockL10nWrapper(DEFAULT_CONTENT)
+ );
+ // NewsletterSnippet is a wrapper around SubmitFormSnippet
+ const { props } = wrapper.children().get(0);
+
+ // the `locale` properties gets used as part of hidden_fields so we
+ // check for it separately
+ const properties = { ...schema.properties };
+ const { locale } = properties;
+ delete properties.locale;
+
+ const defaultProperties = Object.keys(properties).filter(
+ prop => properties[prop].default
+ );
+ assert.lengthOf(defaultProperties, 6);
+ defaultProperties.forEach(prop =>
+ assert.propertyVal(props.content, prop, properties[prop].default)
+ );
+
+ const defaultHiddenProperties = Object.keys(
+ schema.properties.hidden_inputs.properties
+ ).filter(
+ prop => schema.properties.hidden_inputs.properties[prop].default
+ );
+ assert.lengthOf(defaultHiddenProperties, 1);
+ defaultHiddenProperties.forEach(prop =>
+ assert.propertyVal(
+ props.content.hidden_inputs,
+ prop,
+ schema.properties.hidden_inputs.properties[prop].default
+ )
+ );
+ assert.propertyVal(props.content.hidden_inputs, "lang", locale.default);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx
new file mode 100644
index 0000000000..3c60967643
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx
@@ -0,0 +1,277 @@
+import { mount } from "enzyme";
+import React from "react";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+import { LocalizationProvider, ReactLocalization } from "@fluent/react";
+import schema from "content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json";
+import {
+ SendToDeviceSnippet,
+ SendToDeviceScene2Snippet,
+} from "content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet";
+import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs";
+
+async function testBodyContains(body, key, value) {
+ const regex = new RegExp(
+ `Content-Disposition: form-data; name="${key}"${value}`
+ );
+ const match = regex.exec(body);
+ return match;
+}
+
+/**
+ * Simulates opening the second panel (form view), filling in the input, and submitting
+ * @param {EnzymeWrapper} wrapper A SendToDevice wrapper
+ * @param {string} value Email or phone number
+ * @param {function?} setCustomValidity setCustomValidity stub
+ */
+function openFormAndSetValue(wrapper, value, setCustomValidity = () => {}) {
+ // expand
+ wrapper.find(".ASRouterButton").simulate("click");
+ // Fill in email
+ const input = wrapper.find(".mainInput");
+ input.instance().value = value;
+ input.simulate("change", { target: { value, setCustomValidity } });
+ wrapper.find("form").simulate("submit");
+}
+
+describe("SendToDeviceSnippet", () => {
+ let sandbox;
+ let fetchStub;
+ let jsonResponse;
+ let DEFAULT_CONTENT;
+ let DEFAULT_SCENE2_CONTENT;
+
+ function mockL10nWrapper(content) {
+ const bundle = new FluentBundle("en-US");
+ for (const [id, value] of Object.entries(content)) {
+ if (typeof value === "string") {
+ bundle.addResource(new FluentResource(`${id} = ${value}`));
+ }
+ }
+ const l10n = new ReactLocalization([bundle]);
+ return {
+ wrappingComponent: LocalizationProvider,
+ wrappingComponentProps: { l10n },
+ };
+ }
+
+ function mountAndCheckProps(content = {}) {
+ const props = {
+ id: "foo123",
+ content: Object.assign({}, DEFAULT_CONTENT, content),
+ onBlock() {},
+ onDismiss: sandbox.stub(),
+ sendUserActionTelemetry: sandbox.stub(),
+ onAction: sandbox.stub(),
+ };
+ const comp = mount(
+ <SendToDeviceSnippet {...props} />,
+ mockL10nWrapper(props.content)
+ );
+ // Check schema with the final props the component receives (including defaults)
+ assert.jsonSchema(comp.children().get(0).props.content, schema);
+ return comp;
+ }
+
+ beforeEach(async () => {
+ DEFAULT_CONTENT = (await SnippetsTestMessageProvider.getMessages()).find(
+ msg => msg.template === "send_to_device_snippet"
+ ).content;
+ DEFAULT_SCENE2_CONTENT = (
+ await SnippetsTestMessageProvider.getMessages()
+ ).find(msg => msg.template === "send_to_device_scene2_snippet").content;
+ sandbox = sinon.createSandbox();
+ jsonResponse = { status: "ok" };
+ fetchStub = sandbox
+ .stub(global, "fetch")
+ .returns(Promise.resolve({ json: () => Promise.resolve(jsonResponse) }));
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should have the correct defaults", () => {
+ const defaults = {
+ id: "foo123",
+ onBlock() {},
+ content: {},
+ onDismiss: sandbox.stub(),
+ sendUserActionTelemetry: sandbox.stub(),
+ onAction: sandbox.stub(),
+ form_method: "POST",
+ };
+ const wrapper = mount(
+ <SendToDeviceSnippet {...defaults} />,
+ mockL10nWrapper(DEFAULT_CONTENT)
+ );
+ // SendToDeviceSnippet is a wrapper around SubmitFormSnippet
+ const { props } = wrapper.children().get(0);
+
+ const defaultProperties = Object.keys(schema.properties).filter(
+ prop => schema.properties[prop].default
+ );
+ assert.lengthOf(defaultProperties, 7);
+ defaultProperties.forEach(prop =>
+ assert.propertyVal(props.content, prop, schema.properties[prop].default)
+ );
+
+ const defaultHiddenProperties = Object.keys(
+ schema.properties.hidden_inputs.properties
+ ).filter(prop => schema.properties.hidden_inputs.properties[prop].default);
+ assert.lengthOf(defaultHiddenProperties, 0);
+ });
+
+ describe("form input", () => {
+ it("should set the input type to text if content.include_sms is true", () => {
+ const wrapper = mountAndCheckProps({ include_sms: true });
+ wrapper.find(".ASRouterButton").simulate("click");
+ assert.equal(wrapper.find(".mainInput").instance().type, "text");
+ });
+ it("should set the input type to email if content.include_sms is false", () => {
+ const wrapper = mountAndCheckProps({ include_sms: false });
+ wrapper.find(".ASRouterButton").simulate("click");
+ assert.equal(wrapper.find(".mainInput").instance().type, "email");
+ });
+ it("should validate the input with isEmailOrPhoneNumber if include_sms is true", () => {
+ const wrapper = mountAndCheckProps({ include_sms: true });
+ const setCustomValidity = sandbox.stub();
+ openFormAndSetValue(wrapper, "foo", setCustomValidity);
+ assert.calledWith(
+ setCustomValidity,
+ "Must be an email or a phone number."
+ );
+ });
+ it("should not custom validate the input if include_sms is false", () => {
+ const wrapper = mountAndCheckProps({ include_sms: false });
+ const setCustomValidity = sandbox.stub();
+ openFormAndSetValue(wrapper, "foo", setCustomValidity);
+ assert.notCalled(setCustomValidity);
+ });
+ });
+
+ describe("submitting", () => {
+ it("should send the right information to basket.mozilla.org/news/subscribe for an email", async () => {
+ const wrapper = mountAndCheckProps({
+ locale: "fr-CA",
+ include_sms: true,
+ message_id_email: "foo",
+ });
+
+ openFormAndSetValue(wrapper, "foo@bar.com");
+ wrapper.find("form").simulate("submit");
+
+ assert.calledOnce(fetchStub);
+ const [request] = fetchStub.firstCall.args;
+
+ assert.equal(request.url, "https://basket.mozilla.org/news/subscribe/");
+ const body = await request.text();
+ assert.ok(testBodyContains(body, "email", "foo@bar.com"), "has email");
+ assert.ok(testBodyContains(body, "lang", "fr-CA"), "has lang");
+ assert.ok(
+ testBodyContains(body, "newsletters", "foo"),
+ "has newsletters"
+ );
+ assert.ok(
+ testBodyContains(body, "source_url", "foo"),
+ "https%3A%2F%2Fsnippets.mozilla.com%2Fshow%2Ffoo123"
+ );
+ });
+ it("should send the right information for an sms", async () => {
+ const wrapper = mountAndCheckProps({
+ locale: "fr-CA",
+ include_sms: true,
+ message_id_sms: "foo",
+ country: "CA",
+ });
+
+ openFormAndSetValue(wrapper, "5371283767");
+ wrapper.find("form").simulate("submit");
+
+ assert.calledOnce(fetchStub);
+ const [request] = fetchStub.firstCall.args;
+
+ assert.equal(
+ request.url,
+ "https://basket.mozilla.org/news/subscribe_sms/"
+ );
+ const body = await request.text();
+ assert.ok(
+ testBodyContains(body, "mobile_number", "5371283767"),
+ "has number"
+ );
+ assert.ok(testBodyContains(body, "lang", "fr-CA"), "has lang");
+ assert.ok(testBodyContains(body, "country", "CA"), "CA");
+ assert.ok(testBodyContains(body, "msg_name", "foo"), "has msg_name");
+ });
+ });
+
+ describe("SendToDeviceScene2Snippet", () => {
+ function mountWithProps(content = {}) {
+ const props = {
+ id: "foo123",
+ content: Object.assign({}, DEFAULT_SCENE2_CONTENT, content),
+ onBlock() {},
+ onDismiss: sandbox.stub(),
+ sendUserActionTelemetry: sandbox.stub(),
+ onAction: sandbox.stub(),
+ };
+ return mount(
+ <SendToDeviceScene2Snippet {...props} />,
+ mockL10nWrapper(props.content)
+ );
+ }
+
+ it("should render scene 2", () => {
+ const wrapper = mountWithProps();
+
+ assert.lengthOf(wrapper.find(".scene2Icon"), 1, "Found scene 2 icon");
+ assert.lengthOf(
+ wrapper.find(".scene2Title"),
+ 0,
+ "Should not have a large header"
+ );
+ });
+ it("should have block button", () => {
+ const wrapper = mountWithProps();
+
+ assert.lengthOf(
+ wrapper.find(".blockButton"),
+ 1,
+ "Found the block button"
+ );
+ });
+ it("should render title text", () => {
+ const wrapper = mountWithProps();
+
+ assert.lengthOf(
+ wrapper.find(".section-title-text"),
+ 1,
+ "Found the section title"
+ );
+ assert.lengthOf(
+ wrapper.find(".section-title .icon"),
+ 2, // light and dark theme
+ "Found scene 2 title"
+ );
+ });
+ it("should wrap the header in an anchor tag if condition is defined", () => {
+ const sectionTitleProp = {
+ section_title_url: "https://support.mozilla.org",
+ };
+ let wrapper = mountWithProps(sectionTitleProp);
+
+ const element = wrapper.find(".section-title a");
+ assert.lengthOf(element, 1);
+ });
+ it("should render a header without an anchor", () => {
+ const sectionTitleProp = {
+ section_title_url: undefined,
+ };
+ let wrapper = mountWithProps(sectionTitleProp);
+ assert.lengthOf(wrapper.find(".section-title a"), 0);
+ assert.equal(
+ wrapper.find(".section-title").instance().innerText,
+ DEFAULT_SCENE2_CONTENT.section_title_text
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx
new file mode 100644
index 0000000000..df9e544a54
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx
@@ -0,0 +1,81 @@
+import { mount } from "enzyme";
+import React from "react";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+import { LocalizationProvider, ReactLocalization } from "@fluent/react";
+import schema from "content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json";
+import { SimpleBelowSearchSnippet } from "content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx";
+
+const DEFAULT_CONTENT = { text: "foo" };
+
+describe("SimpleBelowSearchSnippet", () => {
+ let sandbox;
+ let sendUserActionTelemetryStub;
+
+ function mockL10nWrapper(content) {
+ const bundle = new FluentBundle("en-US");
+ for (const [id, value] of Object.entries(content)) {
+ if (typeof value === "string") {
+ bundle.addResource(new FluentResource(`${id} = ${value}`));
+ }
+ }
+ const l10n = new ReactLocalization([bundle]);
+ return {
+ wrappingComponent: LocalizationProvider,
+ wrappingComponentProps: { l10n },
+ };
+ }
+
+ /**
+ * mountAndCheckProps - Mounts a SimpleBelowSearchSnippet with DEFAULT_CONTENT extended with any props
+ * passed in the content param and validates props against the schema.
+ * @param {obj} content Object containing custom message content (e.g. {text, icon})
+ * @returns enzyme wrapper for SimpleSnippet
+ */
+ function mountAndCheckProps(content = {}, provider = "test-provider") {
+ const props = {
+ content: { ...DEFAULT_CONTENT, ...content },
+ provider,
+ sendUserActionTelemetry: sendUserActionTelemetryStub,
+ onAction: sandbox.stub(),
+ };
+ assert.jsonSchema(props.content, schema);
+ return mount(
+ <SimpleBelowSearchSnippet {...props} />,
+ mockL10nWrapper(props.content)
+ );
+ }
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ sendUserActionTelemetryStub = sandbox.stub();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render .text", () => {
+ const wrapper = mountAndCheckProps({ text: "bar" });
+ assert.equal(wrapper.find(".body").text(), "bar");
+ });
+
+ it("should render .icon (light theme)", () => {
+ const wrapper = mountAndCheckProps({
+ icon: "data:image/gif;base64,R0lGODl",
+ });
+ assert.equal(
+ wrapper.find(".icon-light-theme").prop("src"),
+ "data:image/gif;base64,R0lGODl"
+ );
+ });
+
+ it("should render .icon (dark theme)", () => {
+ const wrapper = mountAndCheckProps({
+ icon_dark_theme: "data:image/gif;base64,R0lGODl",
+ });
+ assert.equal(
+ wrapper.find(".icon-dark-theme").prop("src"),
+ "data:image/gif;base64,R0lGODl"
+ );
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx
new file mode 100644
index 0000000000..7c169525e4
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx
@@ -0,0 +1,259 @@
+import { mount } from "enzyme";
+import React from "react";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+import { LocalizationProvider, ReactLocalization } from "@fluent/react";
+import schema from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json";
+import { SimpleSnippet } from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx";
+
+const DEFAULT_CONTENT = { text: "foo" };
+
+describe("SimpleSnippet", () => {
+ let sandbox;
+ let onBlockStub;
+ let sendUserActionTelemetryStub;
+
+ function mockL10nWrapper(content) {
+ const bundle = new FluentBundle("en-US");
+ for (const [id, value] of Object.entries(content)) {
+ if (typeof value === "string") {
+ bundle.addResource(new FluentResource(`${id} = ${value}`));
+ }
+ }
+ const l10n = new ReactLocalization([bundle]);
+ return {
+ wrappingComponent: LocalizationProvider,
+ wrappingComponentProps: { l10n },
+ };
+ }
+
+ /**
+ * mountAndCheckProps - Mounts a SimpleSnippet with DEFAULT_CONTENT extended with any props
+ * passed in the content param and validates props against the schema.
+ * @param {obj} content Object containing custom message content (e.g. {text, icon, title})
+ * @returns enzyme wrapper for SimpleSnippet
+ */
+ function mountAndCheckProps(content = {}, provider = "test-provider") {
+ const props = {
+ content: Object.assign({}, DEFAULT_CONTENT, content),
+ provider,
+ onBlock: onBlockStub,
+ sendUserActionTelemetry: sendUserActionTelemetryStub,
+ onAction: sandbox.stub(),
+ };
+ assert.jsonSchema(props.content, schema);
+ return mount(<SimpleSnippet {...props} />, mockL10nWrapper(props.content));
+ }
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ onBlockStub = sandbox.stub();
+ sendUserActionTelemetryStub = sandbox.stub();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should have the correct defaults", () => {
+ const wrapper = mountAndCheckProps();
+ [["button", "title", "block_button_text"]].forEach(prop => {
+ const props = wrapper.find(prop[0]).props();
+ assert.propertyVal(props, prop[1], schema.properties[prop[2]].default);
+ });
+ });
+
+ it("should render .text", () => {
+ const wrapper = mountAndCheckProps({ text: "bar" });
+ assert.equal(wrapper.find(".body").text(), "bar");
+ });
+ it("should not render title element if no .title prop is supplied", () => {
+ const wrapper = mountAndCheckProps();
+ assert.lengthOf(wrapper.find(".title"), 0);
+ });
+ it("should render .title", () => {
+ const wrapper = mountAndCheckProps({ title: "Foo" });
+ assert.equal(wrapper.find(".title").text().trim(), "Foo");
+ });
+ it("should render a light theme variant .icon", () => {
+ const wrapper = mountAndCheckProps({
+ icon: "data:image/gif;base64,R0lGODl",
+ });
+ assert.equal(
+ wrapper.find(".icon-light-theme").prop("src"),
+ "data:image/gif;base64,R0lGODl"
+ );
+ });
+ it("should render a dark theme variant .icon", () => {
+ const wrapper = mountAndCheckProps({
+ icon_dark_theme: "data:image/gif;base64,R0lGODl",
+ });
+ assert.equal(
+ wrapper.find(".icon-dark-theme").prop("src"),
+ "data:image/gif;base64,R0lGODl"
+ );
+ });
+ it("should render a light theme variant .icon as fallback", () => {
+ const wrapper = mountAndCheckProps({
+ icon_dark_theme: "",
+ icon: "data:image/gif;base64,R0lGODp",
+ });
+ assert.equal(
+ wrapper.find(".icon-dark-theme").prop("src"),
+ "data:image/gif;base64,R0lGODp"
+ );
+ });
+ it("should render .button_label and default className", () => {
+ const wrapper = mountAndCheckProps({
+ button_label: "Click here",
+ button_action: "OPEN_APPLICATIONS_MENU",
+ button_action_args: "appMenu",
+ });
+
+ const button = wrapper.find("button.ASRouterButton");
+ button.simulate("click");
+
+ assert.equal(button.text(), "Click here");
+ assert.equal(button.prop("className"), "ASRouterButton secondary");
+ assert.calledOnce(wrapper.props().onAction);
+ assert.calledWithExactly(wrapper.props().onAction, {
+ type: "OPEN_APPLICATIONS_MENU",
+ data: { args: "appMenu" },
+ });
+ });
+ it("should not wrap the main content if a section header is not present", () => {
+ const wrapper = mountAndCheckProps({ text: "bar" });
+ assert.lengthOf(wrapper.find(".innerContentWrapper"), 0);
+ });
+ it("should wrap the main content if a section header is present", () => {
+ const wrapper = mountAndCheckProps({
+ section_title_icon: "data:image/gif;base64,R0lGODl",
+ section_title_text: "Messages from Mozilla",
+ });
+
+ assert.lengthOf(wrapper.find(".innerContentWrapper"), 1);
+ });
+ it("should render a section header if text and icon (light-theme) are specified", () => {
+ const wrapper = mountAndCheckProps({
+ section_title_icon: "data:image/gif;base64,R0lGODl",
+ section_title_text: "Messages from Mozilla",
+ });
+
+ assert.equal(
+ wrapper.find(".section-title .icon-light-theme").prop("style")
+ .backgroundImage,
+ 'url("data:image/gif;base64,R0lGODl")'
+ );
+ assert.equal(
+ wrapper.find(".section-title-text").text().trim(),
+ "Messages from Mozilla"
+ );
+ // ensure there is no <a> when a section_title_url is not specified
+ assert.lengthOf(wrapper.find(".section-title a"), 0);
+ });
+ it("should render a section header if text and icon (light-theme) are specified", () => {
+ const wrapper = mountAndCheckProps({
+ section_title_icon: "data:image/gif;base64,R0lGODl",
+ section_title_icon_dark_theme: "data:image/gif;base64,R0lGODl",
+ section_title_text: "Messages from Mozilla",
+ });
+
+ assert.equal(
+ wrapper.find(".section-title .icon-dark-theme").prop("style")
+ .backgroundImage,
+ 'url("data:image/gif;base64,R0lGODl")'
+ );
+ assert.equal(
+ wrapper.find(".section-title-text").text().trim(),
+ "Messages from Mozilla"
+ );
+ // ensure there is no <a> when a section_title_url is not specified
+ assert.lengthOf(wrapper.find(".section-title a"), 0);
+ });
+ it("should render a section header wrapped in an <a> tag if a url is provided", () => {
+ const wrapper = mountAndCheckProps({
+ section_title_icon: "data:image/gif;base64,R0lGODl",
+ section_title_text: "Messages from Mozilla",
+ section_title_url: "https://www.mozilla.org",
+ });
+
+ assert.equal(
+ wrapper.find(".section-title a").prop("href"),
+ "https://www.mozilla.org"
+ );
+ });
+ it("should send an OPEN_URL action when button_url is defined and button is clicked", () => {
+ const wrapper = mountAndCheckProps({
+ button_label: "Button",
+ button_url: "https://mozilla.org",
+ });
+
+ const button = wrapper.find("button.ASRouterButton");
+ button.simulate("click");
+
+ assert.calledOnce(wrapper.props().onAction);
+ assert.calledWithExactly(wrapper.props().onAction, {
+ type: "OPEN_URL",
+ data: { args: "https://mozilla.org" },
+ });
+ });
+ it("should send an OPEN_ABOUT_PAGE action with entrypoint when the button is clicked", () => {
+ const wrapper = mountAndCheckProps({
+ button_label: "Button",
+ button_action: "OPEN_ABOUT_PAGE",
+ button_entrypoint_value: "snippet",
+ button_entrypoint_name: "entryPoint",
+ button_action_args: "logins",
+ });
+
+ const button = wrapper.find("button.ASRouterButton");
+ button.simulate("click");
+
+ assert.calledOnce(wrapper.props().onAction);
+ assert.calledWithExactly(wrapper.props().onAction, {
+ type: "OPEN_ABOUT_PAGE",
+ data: { args: "logins", entrypoint: "entryPoint=snippet" },
+ });
+ });
+ it("should send an OPEN_PREFERENCE_PAGE action with entrypoint when the button is clicked", () => {
+ const wrapper = mountAndCheckProps({
+ button_label: "Button",
+ button_action: "OPEN_PREFERENCE_PAGE",
+ button_entrypoint_value: "entry=snippet",
+ button_action_args: "home",
+ });
+
+ const button = wrapper.find("button.ASRouterButton");
+ button.simulate("click");
+
+ assert.calledOnce(wrapper.props().onAction);
+ assert.calledWithExactly(wrapper.props().onAction, {
+ type: "OPEN_PREFERENCE_PAGE",
+ data: { args: "home", entrypoint: "entry=snippet" },
+ });
+ });
+ it("should call props.onBlock and sendUserActionTelemetry when CTA button is clicked", () => {
+ const wrapper = mountAndCheckProps({ text: "bar" });
+
+ wrapper.instance().onButtonClick();
+
+ assert.calledOnce(onBlockStub);
+ assert.calledOnce(sendUserActionTelemetryStub);
+ });
+
+ it("should not call props.onBlock if do_not_autoblock is true", () => {
+ const wrapper = mountAndCheckProps({ text: "bar", do_not_autoblock: true });
+
+ wrapper.instance().onButtonClick();
+
+ assert.notCalled(onBlockStub);
+ });
+
+ it("should not call sendUserActionTelemetry for preview message when CTA button is clicked", () => {
+ const wrapper = mountAndCheckProps({ text: "bar" }, "preview");
+
+ wrapper.instance().onButtonClick();
+
+ assert.calledOnce(onBlockStub);
+ assert.notCalled(sendUserActionTelemetryStub);
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx
new file mode 100644
index 0000000000..12e4f96863
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx
@@ -0,0 +1,354 @@
+import { mount } from "enzyme";
+import React from "react";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+import { LocalizationProvider, ReactLocalization } from "@fluent/react";
+import { RichText } from "content-src/asrouter/components/RichText/RichText.jsx";
+import schema from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json";
+import { SubmitFormSnippet } from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx";
+
+const DEFAULT_CONTENT = {
+ scene1_text: "foo",
+ scene2_text: "bar",
+ scene1_button_label: "Sign Up",
+ retry_button_label: "Try again",
+ form_action: "foo.com",
+ hidden_inputs: { foo: "foo" },
+ error_text: "error",
+ success_text: "success",
+};
+
+describe("SubmitFormSnippet", () => {
+ let sandbox;
+ let onBlockStub;
+
+ function mockL10nWrapper(content) {
+ const bundle = new FluentBundle("en-US");
+ for (const [id, value] of Object.entries(content)) {
+ if (typeof value === "string") {
+ bundle.addResource(new FluentResource(`${id} = ${value}`));
+ }
+ }
+ const l10n = new ReactLocalization([bundle]);
+ return {
+ wrappingComponent: LocalizationProvider,
+ wrappingComponentProps: { l10n },
+ };
+ }
+
+ /**
+ * mountAndCheckProps - Mounts a SubmitFormSnippet with DEFAULT_CONTENT extended with any props
+ * passed in the content param and validates props against the schema.
+ * @param {obj} content Object containing custom message content (e.g. {text, icon, title})
+ * @returns enzyme wrapper for SubmitFormSnippet
+ */
+ function mountAndCheckProps(content = {}) {
+ const props = {
+ content: Object.assign({}, DEFAULT_CONTENT, content),
+ onBlock: onBlockStub,
+ onDismiss: sandbox.stub(),
+ sendUserActionTelemetry: sandbox.stub(),
+ onAction: sandbox.stub(),
+ form_method: "POST",
+ };
+ assert.jsonSchema(props.content, schema);
+ return mount(
+ <SubmitFormSnippet {...props} />,
+ mockL10nWrapper(props.content)
+ );
+ }
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ onBlockStub = sandbox.stub();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render .text", () => {
+ const wrapper = mountAndCheckProps({ scene1_text: "bar" });
+ assert.equal(wrapper.find(".body").text(), "bar");
+ });
+ it("should not render title element if no .title prop is supplied", () => {
+ const wrapper = mountAndCheckProps();
+ assert.lengthOf(wrapper.find(".title"), 0);
+ });
+ it("should render .title", () => {
+ const wrapper = mountAndCheckProps({ scene1_title: "Foo" });
+ assert.equal(wrapper.find(".title").text().trim(), "Foo");
+ });
+ it("should render light-theme .icon", () => {
+ const wrapper = mountAndCheckProps({
+ scene1_icon: "data:image/gif;base64,R0lGODl",
+ });
+ assert.equal(
+ wrapper.find(".icon-light-theme").prop("src"),
+ "data:image/gif;base64,R0lGODl"
+ );
+ });
+ it("should render dark-theme .icon", () => {
+ const wrapper = mountAndCheckProps({
+ scene1_icon_dark_theme: "data:image/gif;base64,R0lGODl",
+ });
+ assert.equal(
+ wrapper.find(".icon-dark-theme").prop("src"),
+ "data:image/gif;base64,R0lGODl"
+ );
+ });
+ it("should render .button_label and default className", () => {
+ const wrapper = mountAndCheckProps({ scene1_button_label: "Click here" });
+
+ const button = wrapper.find("button.ASRouterButton");
+ assert.equal(button.text(), "Click here");
+ assert.equal(button.prop("className"), "ASRouterButton secondary");
+ });
+
+ describe("#SignupView", () => {
+ let wrapper;
+ const fetchOk = { json: () => Promise.resolve({ status: "ok" }) };
+ const fetchFail = { json: () => Promise.resolve({ status: "fail" }) };
+
+ beforeEach(() => {
+ wrapper = mountAndCheckProps({
+ scene1_text: "bar",
+ scene2_email_placeholder_text: "Email",
+ scene2_text: "signup",
+ });
+ });
+
+ it("should set the input type if provided through props.inputType", () => {
+ wrapper.setProps({ inputType: "number" });
+ wrapper.setState({ expanded: true });
+ assert.equal(wrapper.find(".mainInput").instance().type, "number");
+ });
+
+ it("should validate via props.validateInput if provided", () => {
+ function validateInput(value, content) {
+ if (content.country === "CA" && value === "poutine") {
+ return "";
+ }
+ return "Must be poutine";
+ }
+ const setCustomValidity = sandbox.stub();
+ wrapper.setProps({
+ validateInput,
+ content: { ...DEFAULT_CONTENT, country: "CA" },
+ });
+ wrapper.setState({ expanded: true });
+ const input = wrapper.find(".mainInput");
+ input.instance().value = "poutine";
+ input.simulate("change", {
+ target: { value: "poutine", setCustomValidity },
+ });
+ assert.calledWith(setCustomValidity, "");
+
+ input.instance().value = "fried chicken";
+ input.simulate("change", {
+ target: { value: "fried chicken", setCustomValidity },
+ });
+ assert.calledWith(setCustomValidity, "Must be poutine");
+ });
+
+ it("should show the signup form if state.expanded is true", () => {
+ wrapper.setState({ expanded: true });
+
+ assert.isTrue(wrapper.find("form").exists());
+ });
+ it("should dismiss the snippet", () => {
+ wrapper.setState({ expanded: true });
+
+ wrapper.find(".ASRouterButton.secondary").simulate("click");
+
+ assert.calledOnce(wrapper.props().onDismiss);
+ });
+ it("should send a DISMISS event ping", () => {
+ wrapper.setState({ expanded: true });
+
+ wrapper.find(".ASRouterButton.secondary").simulate("click");
+
+ assert.equal(
+ wrapper.props().sendUserActionTelemetry.firstCall.args[0].event,
+ "DISMISS"
+ );
+ });
+ it("should render hidden inputs + email input", () => {
+ wrapper.setState({ expanded: true });
+
+ assert.lengthOf(wrapper.find("input[type='hidden']"), 1);
+ });
+ it("should open the SignupView when the action button is clicked", () => {
+ assert.isFalse(wrapper.find("form").exists());
+
+ wrapper.find(".ASRouterButton").simulate("click");
+
+ assert.isTrue(wrapper.state().expanded);
+ assert.isTrue(wrapper.find("form").exists());
+ });
+ it("should submit telemetry when the action button is clicked", () => {
+ assert.isFalse(wrapper.find("form").exists());
+
+ wrapper.find(".ASRouterButton").simulate("click");
+
+ assert.equal(
+ wrapper.props().sendUserActionTelemetry.firstCall.args[0].event_context,
+ "scene1-button-learn-more"
+ );
+ });
+ it("should submit form data when submitted", () => {
+ sandbox.stub(window, "fetch").resolves(fetchOk);
+ wrapper.setState({ expanded: true });
+
+ wrapper.find("form").simulate("submit");
+ assert.calledOnce(window.fetch);
+ });
+ it("should send user telemetry when submitted", () => {
+ wrapper.setState({ expanded: true });
+
+ wrapper.find("form").simulate("submit");
+
+ assert.equal(
+ wrapper.props().sendUserActionTelemetry.firstCall.args[0].event_context,
+ "conversion-subscribe-activation"
+ );
+ });
+ it("should set signupSuccess when submission status is ok", async () => {
+ sandbox.stub(window, "fetch").resolves(fetchOk);
+ wrapper.setState({ expanded: true });
+ await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
+
+ assert.equal(wrapper.state().signupSuccess, true);
+ assert.equal(wrapper.state().signupSubmitted, true);
+ assert.calledOnce(onBlockStub);
+ assert.calledWithExactly(onBlockStub, { preventDismiss: true });
+ });
+ it("should send user telemetry when submission status is ok", async () => {
+ sandbox.stub(window, "fetch").resolves(fetchOk);
+ wrapper.setState({ expanded: true });
+ await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
+
+ assert.equal(
+ wrapper.props().sendUserActionTelemetry.secondCall.args[0]
+ .event_context,
+ "subscribe-success"
+ );
+ });
+ it("should not block the snippet if submission failed", async () => {
+ sandbox.stub(window, "fetch").resolves(fetchFail);
+ wrapper.setState({ expanded: true });
+ await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
+
+ assert.equal(wrapper.state().signupSuccess, false);
+ assert.equal(wrapper.state().signupSubmitted, true);
+ assert.notCalled(onBlockStub);
+ });
+ it("should not block if do_not_autoblock is true", async () => {
+ sandbox.stub(window, "fetch").resolves(fetchOk);
+ wrapper = mountAndCheckProps({
+ scene1_text: "bar",
+ scene2_email_placeholder_text: "Email",
+ scene2_text: "signup",
+ do_not_autoblock: true,
+ });
+ wrapper.setState({ expanded: true });
+ await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
+
+ assert.equal(wrapper.state().signupSuccess, true);
+ assert.equal(wrapper.state().signupSubmitted, true);
+ assert.notCalled(onBlockStub);
+ });
+ it("should send user telemetry if submission failed", async () => {
+ sandbox.stub(window, "fetch").resolves(fetchFail);
+ wrapper.setState({ expanded: true });
+ await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
+
+ assert.equal(
+ wrapper.props().sendUserActionTelemetry.secondCall.args[0]
+ .event_context,
+ "subscribe-error"
+ );
+ });
+ it("should render the signup success message", () => {
+ wrapper.setProps({ content: { success_text: "success" } });
+ wrapper.setState({ signupSuccess: true, signupSubmitted: true });
+
+ assert.isTrue(wrapper.find(".submissionStatus").exists());
+ assert.propertyVal(
+ wrapper.find(RichText).props(),
+ "localization_id",
+ "success_text"
+ );
+ assert.propertyVal(
+ wrapper.find(RichText).props(),
+ "success_text",
+ "success"
+ );
+ assert.isFalse(wrapper.find(".ASRouterButton").exists());
+ });
+ it("should render the signup error message", () => {
+ wrapper.setProps({ content: { error_text: "trouble" } });
+ wrapper.setState({ signupSuccess: false, signupSubmitted: true });
+
+ assert.isTrue(wrapper.find(".submissionStatus").exists());
+ assert.propertyVal(
+ wrapper.find(RichText).props(),
+ "localization_id",
+ "error_text"
+ );
+ assert.propertyVal(
+ wrapper.find(RichText).props(),
+ "error_text",
+ "trouble"
+ );
+ assert.isTrue(wrapper.find(".ASRouterButton").exists());
+ });
+ it("should render the button to return to the signup form if there was an error", () => {
+ wrapper.setState({ signupSubmitted: true, signupSuccess: false });
+
+ const button = wrapper.find("button.ASRouterButton");
+ assert.equal(button.text(), "Try again");
+ wrapper.find(".ASRouterButton").simulate("click");
+
+ assert.equal(wrapper.state().signupSubmitted, false);
+ });
+ it("should not render the privacy notice checkbox if prop is missing", () => {
+ wrapper.setState({ expanded: true });
+
+ assert.isFalse(wrapper.find(".privacyNotice").exists());
+ });
+ it("should render the privacy notice checkbox if prop is provided", () => {
+ wrapper.setProps({
+ content: { ...DEFAULT_CONTENT, scene2_privacy_html: "privacy notice" },
+ });
+ wrapper.setState({ expanded: true });
+
+ assert.isTrue(wrapper.find(".privacyNotice").exists());
+ });
+ it("should not call fetch if form_method is GET", async () => {
+ sandbox.stub(window, "fetch").resolves(fetchOk);
+ wrapper.setProps({ form_method: "GET" });
+ wrapper.setState({ expanded: true });
+
+ await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
+
+ assert.notCalled(window.fetch);
+ });
+ it("should block the snippet when form_method is GET", () => {
+ wrapper.setProps({ form_method: "GET" });
+ wrapper.setState({ expanded: true });
+
+ wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() });
+
+ assert.calledOnce(onBlockStub);
+ assert.calledWithExactly(onBlockStub, { preventDismiss: true });
+ });
+ it("should return to scene 2 alt when clicking the retry button", async () => {
+ wrapper.setState({ signupSubmitted: true });
+ wrapper.setProps({ expandedAlt: true });
+
+ wrapper.find(".ASRouterButton").simulate("click");
+
+ assert.isTrue(wrapper.find(".scene2Alt").exists());
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js b/browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js
new file mode 100644
index 0000000000..32eaf2160e
--- /dev/null
+++ b/browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js
@@ -0,0 +1,56 @@
+import { isEmailOrPhoneNumber } from "content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber";
+
+const CONTENT = {};
+
+describe("isEmailOrPhoneNumber", () => {
+ it("should return 'email' for emails", () => {
+ assert.equal(isEmailOrPhoneNumber("foobar@asd.com", CONTENT), "email");
+ assert.equal(isEmailOrPhoneNumber("foobar@asd.co.uk", CONTENT), "email");
+ });
+ it("should return 'phone' for valid en-US/en-CA phone numbers", () => {
+ assert.equal(
+ isEmailOrPhoneNumber("14582731273", { locale: "en-US" }),
+ "phone"
+ );
+ assert.equal(
+ isEmailOrPhoneNumber("4582731273", { locale: "en-CA" }),
+ "phone"
+ );
+ });
+ it("should return an empty string for invalid phone number lengths in en-US/en-CA", () => {
+ // Not enough digits
+ assert.equal(isEmailOrPhoneNumber("4522", { locale: "en-US" }), "");
+ assert.equal(isEmailOrPhoneNumber("4522", { locale: "en-CA" }), "");
+ });
+ it("should return 'phone' for valid German phone numbers", () => {
+ assert.equal(
+ isEmailOrPhoneNumber("145827312732", { locale: "de" }),
+ "phone"
+ );
+ });
+ it("should return 'phone' for any number of digits in other locales", () => {
+ assert.equal(isEmailOrPhoneNumber("4", CONTENT), "phone");
+ });
+ it("should return an empty string for other invalid inputs", () => {
+ assert.equal(
+ isEmailOrPhoneNumber("abc", CONTENT),
+ "",
+ "abc should be invalid"
+ );
+ assert.equal(
+ isEmailOrPhoneNumber("abc@", CONTENT),
+ "",
+ "abc@ should be invalid"
+ );
+ assert.equal(
+ isEmailOrPhoneNumber("abc@foo", CONTENT),
+ "",
+ "abc@foo should be invalid"
+ );
+ assert.equal(
+ isEmailOrPhoneNumber("123d1232", CONTENT),
+ "",
+ "123d1232 should be invalid"
+ );
+ });
+});
diff --git a/browser/components/newtab/test/unit/common/Actions.test.js b/browser/components/newtab/test/unit/common/Actions.test.js
new file mode 100644
index 0000000000..32e417ea3f
--- /dev/null
+++ b/browser/components/newtab/test/unit/common/Actions.test.js
@@ -0,0 +1,236 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+ actionUtils as au,
+ BACKGROUND_PROCESS,
+ CONTENT_MESSAGE_TYPE,
+ globalImportContext,
+ MAIN_MESSAGE_TYPE,
+ PRELOAD_MESSAGE_TYPE,
+ UI_CODE,
+} from "common/Actions.sys.mjs";
+
+describe("Actions", () => {
+ it("should set globalImportContext to UI_CODE", () => {
+ assert.equal(globalImportContext, UI_CODE);
+ });
+});
+
+describe("ActionTypes", () => {
+ it("should be in alpha order", () => {
+ assert.equal(Object.keys(at).join(", "), Object.keys(at).sort().join(", "));
+ });
+});
+
+describe("ActionCreators", () => {
+ describe("_RouteMessage", () => {
+ it("should throw if options are not passed as the second param", () => {
+ assert.throws(() => {
+ au._RouteMessage({ type: "FOO" });
+ });
+ });
+ it("should set all defined options on the .meta property of the new action", () => {
+ assert.deepEqual(
+ au._RouteMessage(
+ { type: "FOO", meta: { hello: "world" } },
+ { from: "foo", to: "bar" }
+ ),
+ { type: "FOO", meta: { hello: "world", from: "foo", to: "bar" } }
+ );
+ });
+ it("should remove any undefined options related to message routing", () => {
+ const action = au._RouteMessage(
+ { type: "FOO", meta: { fromTarget: "bar" } },
+ { from: "foo", to: "bar" }
+ );
+ assert.isUndefined(action.meta.fromTarget);
+ });
+ });
+ describe("AlsoToMain", () => {
+ it("should create the right action", () => {
+ const action = { type: "FOO", data: "BAR" };
+ const newAction = ac.AlsoToMain(action);
+ assert.deepEqual(newAction, {
+ type: "FOO",
+ data: "BAR",
+ meta: { from: CONTENT_MESSAGE_TYPE, to: MAIN_MESSAGE_TYPE },
+ });
+ });
+ it("should add the fromTarget if it was supplied", () => {
+ const action = { type: "FOO", data: "BAR" };
+ const newAction = ac.AlsoToMain(action, "port123");
+ assert.equal(newAction.meta.fromTarget, "port123");
+ });
+ describe("isSendToMain", () => {
+ it("should return true if action is AlsoToMain", () => {
+ const newAction = ac.AlsoToMain({ type: "FOO" });
+ assert.isTrue(au.isSendToMain(newAction));
+ });
+ it("should return false if action is not AlsoToMain", () => {
+ assert.isFalse(au.isSendToMain({ type: "FOO" }));
+ });
+ });
+ });
+ describe("AlsoToOneContent", () => {
+ it("should create the right action", () => {
+ const action = { type: "FOO", data: "BAR" };
+ const targetId = "abc123";
+ const newAction = ac.AlsoToOneContent(action, targetId);
+ assert.deepEqual(newAction, {
+ type: "FOO",
+ data: "BAR",
+ meta: {
+ from: MAIN_MESSAGE_TYPE,
+ to: CONTENT_MESSAGE_TYPE,
+ toTarget: targetId,
+ },
+ });
+ });
+ it("should throw if no targetId is provided", () => {
+ assert.throws(() => {
+ ac.AlsoToOneContent({ type: "FOO" });
+ });
+ });
+ describe("isSendToOneContent", () => {
+ it("should return true if action is AlsoToOneContent", () => {
+ const newAction = ac.AlsoToOneContent({ type: "FOO" }, "foo123");
+ assert.isTrue(au.isSendToOneContent(newAction));
+ });
+ it("should return false if action is not AlsoToMain", () => {
+ assert.isFalse(au.isSendToOneContent({ type: "FOO" }));
+ assert.isFalse(
+ au.isSendToOneContent(ac.BroadcastToContent({ type: "FOO" }))
+ );
+ });
+ });
+ describe("isFromMain", () => {
+ it("should return true if action is AlsoToOneContent", () => {
+ const newAction = ac.AlsoToOneContent({ type: "FOO" }, "foo123");
+ assert.isTrue(au.isFromMain(newAction));
+ });
+ it("should return true if action is BroadcastToContent", () => {
+ const newAction = ac.BroadcastToContent({ type: "FOO" });
+ assert.isTrue(au.isFromMain(newAction));
+ });
+ it("should return false if action is AlsoToMain", () => {
+ const newAction = ac.AlsoToMain({ type: "FOO" });
+ assert.isFalse(au.isFromMain(newAction));
+ });
+ });
+ });
+ describe("BroadcastToContent", () => {
+ it("should create the right action", () => {
+ const action = { type: "FOO", data: "BAR" };
+ const newAction = ac.BroadcastToContent(action);
+ assert.deepEqual(newAction, {
+ type: "FOO",
+ data: "BAR",
+ meta: { from: MAIN_MESSAGE_TYPE, to: CONTENT_MESSAGE_TYPE },
+ });
+ });
+ describe("isBroadcastToContent", () => {
+ it("should return true if action is BroadcastToContent", () => {
+ assert.isTrue(
+ au.isBroadcastToContent(ac.BroadcastToContent({ type: "FOO" }))
+ );
+ });
+ it("should return false if action is not BroadcastToContent", () => {
+ assert.isFalse(au.isBroadcastToContent({ type: "FOO" }));
+ assert.isFalse(
+ au.isBroadcastToContent(
+ ac.AlsoToOneContent({ type: "FOO" }, "foo123")
+ )
+ );
+ });
+ });
+ });
+ describe("AlsoToPreloaded", () => {
+ it("should create the right action", () => {
+ const action = { type: "FOO", data: "BAR" };
+ const newAction = ac.AlsoToPreloaded(action);
+ assert.deepEqual(newAction, {
+ type: "FOO",
+ data: "BAR",
+ meta: { from: MAIN_MESSAGE_TYPE, to: PRELOAD_MESSAGE_TYPE },
+ });
+ });
+ });
+ describe("isSendToPreloaded", () => {
+ it("should return true if action is AlsoToPreloaded", () => {
+ assert.isTrue(au.isSendToPreloaded(ac.AlsoToPreloaded({ type: "FOO" })));
+ });
+ it("should return false if action is not AlsoToPreloaded", () => {
+ assert.isFalse(au.isSendToPreloaded({ type: "FOO" }));
+ assert.isFalse(
+ au.isSendToPreloaded(ac.BroadcastToContent({ type: "FOO" }))
+ );
+ });
+ });
+ describe("UserEvent", () => {
+ it("should include the given data", () => {
+ const data = { action: "foo" };
+ assert.equal(ac.UserEvent(data).data, data);
+ });
+ it("should wrap with AlsoToMain", () => {
+ const action = ac.UserEvent({ action: "foo" });
+ assert.isTrue(au.isSendToMain(action), "isSendToMain");
+ });
+ });
+ describe("ASRouterUserEvent", () => {
+ it("should include the given data", () => {
+ const data = { action: "foo" };
+ assert.equal(ac.ASRouterUserEvent(data).data, data);
+ });
+ it("should wrap with AlsoToMain", () => {
+ const action = ac.ASRouterUserEvent({ action: "foo" });
+ assert.isTrue(au.isSendToMain(action), "isSendToMain");
+ });
+ });
+ describe("ImpressionStats", () => {
+ it("should include the right data", () => {
+ const data = { action: "foo" };
+ assert.equal(ac.ImpressionStats(data).data, data);
+ });
+ it("should wrap with AlsoToMain if in UI code", () => {
+ assert.isTrue(
+ au.isSendToMain(ac.ImpressionStats({ action: "foo" })),
+ "isSendToMain"
+ );
+ });
+ it("should not wrap with AlsoToMain if not in UI code", () => {
+ const action = ac.ImpressionStats({ action: "foo" }, BACKGROUND_PROCESS);
+ assert.isFalse(au.isSendToMain(action), "isSendToMain");
+ });
+ });
+ describe("WebExtEvent", () => {
+ it("should set the provided type", () => {
+ const action = ac.WebExtEvent(at.WEBEXT_CLICK, {
+ source: "MyExtension",
+ url: "foo.com",
+ });
+ assert.equal(action.type, at.WEBEXT_CLICK);
+ });
+ it("should set the provided data", () => {
+ const data = { source: "MyExtension", url: "foo.com" };
+ const action = ac.WebExtEvent(at.WEBEXT_CLICK, data);
+ assert.equal(action.data, data);
+ });
+ it("should throw if the 'source' property is missing", () => {
+ assert.throws(() => {
+ ac.WebExtEvent(at.WEBEXT_CLICK, {});
+ });
+ });
+ });
+});
+
+describe("ActionUtils", () => {
+ describe("getPortIdOfSender", () => {
+ it("should return the PortID from a AlsoToMain action", () => {
+ const portID = "foo123";
+ const result = au.getPortIdOfSender(
+ ac.AlsoToMain({ type: "FOO" }, portID)
+ );
+ assert.equal(result, portID);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/common/Dedupe.test.js b/browser/components/newtab/test/unit/common/Dedupe.test.js
new file mode 100644
index 0000000000..1c85eafa50
--- /dev/null
+++ b/browser/components/newtab/test/unit/common/Dedupe.test.js
@@ -0,0 +1,38 @@
+import { Dedupe } from "common/Dedupe.sys.mjs";
+
+describe("Dedupe", () => {
+ let instance;
+ beforeEach(() => {
+ instance = new Dedupe();
+ });
+ describe("group", () => {
+ it("should remove duplicates inside the groups", () => {
+ const beforeItems = [
+ [1, 1, 1],
+ [2, 2, 2],
+ [3, 3, 3],
+ ];
+ const afterItems = [[1], [2], [3]];
+ assert.deepEqual(instance.group(...beforeItems), afterItems);
+ });
+ it("should remove duplicates between groups, favouring earlier groups", () => {
+ const beforeItems = [
+ [1, 2, 3],
+ [2, 3, 4],
+ [3, 4, 5],
+ ];
+ const afterItems = [[1, 2, 3], [4], [5]];
+ assert.deepEqual(instance.group(...beforeItems), afterItems);
+ });
+ it("should remove duplicates from groups of objects", () => {
+ instance = new Dedupe(item => item.id);
+ const beforeItems = [
+ [{ id: 1 }, { id: 1 }, { id: 2 }],
+ [{ id: 1 }, { id: 3 }, { id: 2 }],
+ [{ id: 1 }, { id: 2 }, { id: 5 }],
+ ];
+ const afterItems = [[{ id: 1 }, { id: 2 }], [{ id: 3 }], [{ id: 5 }]];
+ assert.deepEqual(instance.group(...beforeItems), afterItems);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/common/Reducers.test.js b/browser/components/newtab/test/unit/common/Reducers.test.js
new file mode 100644
index 0000000000..ab2a5b4d62
--- /dev/null
+++ b/browser/components/newtab/test/unit/common/Reducers.test.js
@@ -0,0 +1,1566 @@
+import { INITIAL_STATE, insertPinned, reducers } from "common/Reducers.sys.mjs";
+const {
+ TopSites,
+ App,
+ Snippets,
+ Prefs,
+ Dialog,
+ Sections,
+ Pocket,
+ Personalization,
+ DiscoveryStream,
+ Search,
+ ASRouter,
+} = reducers;
+import { actionTypes as at } from "common/Actions.sys.mjs";
+
+describe("Reducers", () => {
+ describe("App", () => {
+ it("should return the initial state", () => {
+ const nextState = App(undefined, { type: "FOO" });
+ assert.equal(nextState, INITIAL_STATE.App);
+ });
+ it("should set initialized to true on INIT", () => {
+ const nextState = App(undefined, { type: "INIT" });
+
+ assert.propertyVal(nextState, "initialized", true);
+ });
+ });
+ describe("TopSites", () => {
+ it("should return the initial state", () => {
+ const nextState = TopSites(undefined, { type: "FOO" });
+ assert.equal(nextState, INITIAL_STATE.TopSites);
+ });
+ it("should add top sites on TOP_SITES_UPDATED", () => {
+ const newRows = [{ url: "foo.com" }, { url: "bar.com" }];
+ const nextState = TopSites(undefined, {
+ type: at.TOP_SITES_UPDATED,
+ data: { links: newRows },
+ });
+ assert.equal(nextState.rows, newRows);
+ });
+ it("should not update state for empty action.data on TOP_SITES_UPDATED", () => {
+ const nextState = TopSites(undefined, { type: at.TOP_SITES_UPDATED });
+ assert.equal(nextState, INITIAL_STATE.TopSites);
+ });
+ it("should initialize prefs on TOP_SITES_UPDATED", () => {
+ const nextState = TopSites(undefined, {
+ type: at.TOP_SITES_UPDATED,
+ data: { links: [], pref: "foo" },
+ });
+
+ assert.equal(nextState.pref, "foo");
+ });
+ it("should pass prevState.prefs if not present in TOP_SITES_UPDATED", () => {
+ const nextState = TopSites(
+ { prefs: "foo" },
+ { type: at.TOP_SITES_UPDATED, data: { links: [] } }
+ );
+
+ assert.equal(nextState.prefs, "foo");
+ });
+ it("should set editForm.site to action.data on TOP_SITES_EDIT", () => {
+ const data = { index: 7 };
+ const nextState = TopSites(undefined, { type: at.TOP_SITES_EDIT, data });
+ assert.equal(nextState.editForm.index, data.index);
+ });
+ it("should set editForm to null on TOP_SITES_CANCEL_EDIT", () => {
+ const nextState = TopSites(undefined, { type: at.TOP_SITES_CANCEL_EDIT });
+ assert.isNull(nextState.editForm);
+ });
+ it("should preserve the editForm.index", () => {
+ const actionTypes = [
+ at.PREVIEW_RESPONSE,
+ at.PREVIEW_REQUEST,
+ at.PREVIEW_REQUEST_CANCEL,
+ ];
+ actionTypes.forEach(type => {
+ const oldState = { editForm: { index: 0, previewUrl: "foo" } };
+ const action = { type, data: { url: "foo" } };
+ const nextState = TopSites(oldState, action);
+ assert.equal(nextState.editForm.index, 0);
+ });
+ });
+ it("should set previewResponse on PREVIEW_RESPONSE", () => {
+ const oldState = { editForm: { previewUrl: "url" } };
+ const action = {
+ type: at.PREVIEW_RESPONSE,
+ data: { preview: "data:123", url: "url" },
+ };
+ const nextState = TopSites(oldState, action);
+ assert.propertyVal(nextState.editForm, "previewResponse", "data:123");
+ });
+ it("should return previous state if action url does not match expected", () => {
+ const oldState = { editForm: { previewUrl: "foo" } };
+ const action = { type: at.PREVIEW_RESPONSE, data: { url: "bar" } };
+ const nextState = TopSites(oldState, action);
+ assert.equal(nextState, oldState);
+ });
+ it("should return previous state if editForm is not set", () => {
+ const actionTypes = [
+ at.PREVIEW_RESPONSE,
+ at.PREVIEW_REQUEST,
+ at.PREVIEW_REQUEST_CANCEL,
+ ];
+ actionTypes.forEach(type => {
+ const oldState = { editForm: null };
+ const action = { type, data: { url: "bar" } };
+ const nextState = TopSites(oldState, action);
+ assert.equal(nextState, oldState, type);
+ });
+ });
+ it("should set previewResponse to null on PREVIEW_REQUEST", () => {
+ const oldState = { editForm: { previewResponse: "foo" } };
+ const action = { type: at.PREVIEW_REQUEST, data: {} };
+ const nextState = TopSites(oldState, action);
+ assert.propertyVal(nextState.editForm, "previewResponse", null);
+ });
+ it("should set previewUrl on PREVIEW_REQUEST", () => {
+ const oldState = { editForm: {} };
+ const action = { type: at.PREVIEW_REQUEST, data: { url: "bar" } };
+ const nextState = TopSites(oldState, action);
+ assert.propertyVal(nextState.editForm, "previewUrl", "bar");
+ });
+ it("should add screenshots for SCREENSHOT_UPDATED", () => {
+ const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
+ const action = {
+ type: at.SCREENSHOT_UPDATED,
+ data: { url: "bar.com", screenshot: "data:123" },
+ };
+ const nextState = TopSites(oldState, action);
+ assert.deepEqual(nextState.rows, [
+ { url: "foo.com" },
+ { url: "bar.com", screenshot: "data:123" },
+ ]);
+ });
+ it("should not modify rows if nothing matches the url for SCREENSHOT_UPDATED", () => {
+ const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
+ const action = {
+ type: at.SCREENSHOT_UPDATED,
+ data: { url: "baz.com", screenshot: "data:123" },
+ };
+ const nextState = TopSites(oldState, action);
+ assert.deepEqual(nextState, oldState);
+ });
+ it("should bookmark an item on PLACES_BOOKMARK_ADDED", () => {
+ const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
+ const action = {
+ type: at.PLACES_BOOKMARK_ADDED,
+ data: {
+ url: "bar.com",
+ bookmarkGuid: "bookmark123",
+ bookmarkTitle: "Title for bar.com",
+ dateAdded: 1234567,
+ },
+ };
+ const nextState = TopSites(oldState, action);
+ const [, newRow] = nextState.rows;
+ // new row has bookmark data
+ assert.equal(newRow.url, action.data.url);
+ assert.equal(newRow.bookmarkGuid, action.data.bookmarkGuid);
+ assert.equal(newRow.bookmarkTitle, action.data.bookmarkTitle);
+ assert.equal(newRow.bookmarkDateCreated, action.data.dateAdded);
+
+ // old row is unchanged
+ assert.equal(nextState.rows[0], oldState.rows[0]);
+ });
+ it("should not update state for empty action.data on PLACES_BOOKMARK_ADDED", () => {
+ const nextState = TopSites(undefined, { type: at.PLACES_BOOKMARK_ADDED });
+ assert.equal(nextState, INITIAL_STATE.TopSites);
+ });
+ it("should remove a bookmark on PLACES_BOOKMARKS_REMOVED", () => {
+ const oldState = {
+ rows: [
+ { url: "foo.com" },
+ {
+ url: "bar.com",
+ bookmarkGuid: "bookmark123",
+ bookmarkTitle: "Title for bar.com",
+ dateAdded: 123456,
+ },
+ ],
+ };
+ const action = {
+ type: at.PLACES_BOOKMARKS_REMOVED,
+ data: { urls: ["bar.com"] },
+ };
+ const nextState = TopSites(oldState, action);
+ const [, newRow] = nextState.rows;
+ // new row no longer has bookmark data
+ assert.equal(newRow.url, oldState.rows[1].url);
+ assert.isUndefined(newRow.bookmarkGuid);
+ assert.isUndefined(newRow.bookmarkTitle);
+ assert.isUndefined(newRow.bookmarkDateCreated);
+
+ // old row is unchanged
+ assert.deepEqual(nextState.rows[0], oldState.rows[0]);
+ });
+ it("should not update state for empty action.data on PLACES_BOOKMARKS_REMOVED", () => {
+ const nextState = TopSites(undefined, {
+ type: at.PLACES_BOOKMARKS_REMOVED,
+ });
+ assert.equal(nextState, INITIAL_STATE.TopSites);
+ });
+ it("should update prefs on TOP_SITES_PREFS_UPDATED", () => {
+ const state = TopSites(
+ {},
+ { type: at.TOP_SITES_PREFS_UPDATED, data: { pref: "foo" } }
+ );
+
+ assert.equal(state.pref, "foo");
+ });
+ it("should not update state for empty action.data on PLACES_LINKS_DELETED", () => {
+ const nextState = TopSites(undefined, { type: at.PLACES_LINKS_DELETED });
+ assert.equal(nextState, INITIAL_STATE.TopSites);
+ });
+ it("should remove the site on PLACES_LINKS_DELETED", () => {
+ const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
+ const deleteAction = {
+ type: at.PLACES_LINKS_DELETED,
+ data: { urls: ["foo.com"] },
+ };
+ const nextState = TopSites(oldState, deleteAction);
+ assert.deepEqual(nextState.rows, [{ url: "bar.com" }]);
+ });
+ it("should set showSearchShortcutsForm to true on TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", () => {
+ const data = { index: 7 };
+ const nextState = TopSites(undefined, {
+ type: at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL,
+ data,
+ });
+ assert.isTrue(nextState.showSearchShortcutsForm);
+ });
+ it("should set showSearchShortcutsForm to false on TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", () => {
+ const nextState = TopSites(undefined, {
+ type: at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL,
+ });
+ assert.isFalse(nextState.showSearchShortcutsForm);
+ });
+ it("should update searchShortcuts on UPDATE_SEARCH_SHORTCUTS", () => {
+ const shortcuts = [
+ {
+ keyword: "@google",
+ shortURL: "google",
+ url: "https://google.com",
+ searchIdentifier: /^google/,
+ },
+ {
+ keyword: "@baidu",
+ shortURL: "baidu",
+ url: "https://baidu.com",
+ searchIdentifier: /^baidu/,
+ },
+ ];
+ const nextState = TopSites(undefined, {
+ type: at.UPDATE_SEARCH_SHORTCUTS,
+ data: { searchShortcuts: shortcuts },
+ });
+ assert.deepEqual(shortcuts, nextState.searchShortcuts);
+ });
+ it("should remove all content on SNIPPETS_PREVIEW_MODE", () => {
+ const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] };
+ const nextState = TopSites(oldState, { type: at.SNIPPETS_PREVIEW_MODE });
+ assert.lengthOf(nextState.rows, 0);
+ });
+ });
+ describe("Prefs", () => {
+ function prevState(custom = {}) {
+ return Object.assign({}, INITIAL_STATE.Prefs, custom);
+ }
+ it("should have the correct initial state", () => {
+ const state = Prefs(undefined, {});
+ assert.deepEqual(state, INITIAL_STATE.Prefs);
+ });
+ describe("PREFS_INITIAL_VALUES", () => {
+ it("should return a new object", () => {
+ const state = Prefs(undefined, {
+ type: at.PREFS_INITIAL_VALUES,
+ data: {},
+ });
+ assert.notEqual(
+ INITIAL_STATE.Prefs,
+ state,
+ "should not modify INITIAL_STATE"
+ );
+ });
+ it("should set initalized to true", () => {
+ const state = Prefs(undefined, {
+ type: at.PREFS_INITIAL_VALUES,
+ data: {},
+ });
+ assert.isTrue(state.initialized);
+ });
+ it("should set .values", () => {
+ const newValues = { foo: 1, bar: 2 };
+ const state = Prefs(undefined, {
+ type: at.PREFS_INITIAL_VALUES,
+ data: newValues,
+ });
+ assert.equal(state.values, newValues);
+ });
+ });
+ describe("PREF_CHANGED", () => {
+ it("should return a new Prefs object", () => {
+ const state = Prefs(undefined, {
+ type: at.PREF_CHANGED,
+ data: { name: "foo", value: 2 },
+ });
+ assert.notEqual(
+ INITIAL_STATE.Prefs,
+ state,
+ "should not modify INITIAL_STATE"
+ );
+ });
+ it("should set the changed pref", () => {
+ const state = Prefs(prevState({ foo: 1 }), {
+ type: at.PREF_CHANGED,
+ data: { name: "foo", value: 2 },
+ });
+ assert.equal(state.values.foo, 2);
+ });
+ it("should return a new .pref object instead of mutating", () => {
+ const oldState = prevState({ foo: 1 });
+ const state = Prefs(oldState, {
+ type: at.PREF_CHANGED,
+ data: { name: "foo", value: 2 },
+ });
+ assert.notEqual(oldState.values, state.values);
+ });
+ });
+ });
+ describe("Dialog", () => {
+ it("should return INITIAL_STATE by default", () => {
+ assert.equal(
+ INITIAL_STATE.Dialog,
+ Dialog(undefined, { type: "non_existent" })
+ );
+ });
+ it("should toggle visible to true on DIALOG_OPEN", () => {
+ const action = { type: at.DIALOG_OPEN };
+ const nextState = Dialog(INITIAL_STATE.Dialog, action);
+ assert.isTrue(nextState.visible);
+ });
+ it("should pass url data on DIALOG_OPEN", () => {
+ const action = { type: at.DIALOG_OPEN, data: "some url" };
+ const nextState = Dialog(INITIAL_STATE.Dialog, action);
+ assert.equal(nextState.data, action.data);
+ });
+ it("should toggle visible to false on DIALOG_CANCEL", () => {
+ const action = { type: at.DIALOG_CANCEL, data: "some url" };
+ const nextState = Dialog(INITIAL_STATE.Dialog, action);
+ assert.isFalse(nextState.visible);
+ });
+ it("should return inital state on DELETE_HISTORY_URL", () => {
+ const action = { type: at.DELETE_HISTORY_URL };
+ const nextState = Dialog(INITIAL_STATE.Dialog, action);
+
+ assert.deepEqual(INITIAL_STATE.Dialog, nextState);
+ });
+ });
+ describe("Sections", () => {
+ let oldState;
+
+ beforeEach(() => {
+ oldState = new Array(5).fill(null).map((v, i) => ({
+ id: `foo_bar_${i}`,
+ title: `Foo Bar ${i}`,
+ initialized: false,
+ rows: [
+ { url: "www.foo.bar", pocket_id: 123 },
+ { url: "www.other.url" },
+ ],
+ order: i,
+ type: "history",
+ }));
+ });
+
+ it("should return INITIAL_STATE by default", () => {
+ assert.equal(
+ INITIAL_STATE.Sections,
+ Sections(undefined, { type: "non_existent" })
+ );
+ });
+ it("should remove the correct section on SECTION_DEREGISTER", () => {
+ const newState = Sections(oldState, {
+ type: at.SECTION_DEREGISTER,
+ data: "foo_bar_2",
+ });
+ assert.lengthOf(newState, 4);
+ const expectedNewState = oldState.splice(2, 1) && oldState;
+ assert.deepEqual(newState, expectedNewState);
+ });
+ it("should add a section on SECTION_REGISTER if it doesn't already exist", () => {
+ const action = {
+ type: at.SECTION_REGISTER,
+ data: { id: "foo_bar_5", title: "Foo Bar 5" },
+ };
+ const newState = Sections(oldState, action);
+ assert.lengthOf(newState, 6);
+ const insertedSection = newState.find(
+ section => section.id === "foo_bar_5"
+ );
+ assert.propertyVal(insertedSection, "title", action.data.title);
+ });
+ it("should set newSection.rows === [] if no rows are provided on SECTION_REGISTER", () => {
+ const action = {
+ type: at.SECTION_REGISTER,
+ data: { id: "foo_bar_5", title: "Foo Bar 5" },
+ };
+ const newState = Sections(oldState, action);
+ const insertedSection = newState.find(
+ section => section.id === "foo_bar_5"
+ );
+ assert.deepEqual(insertedSection.rows, []);
+ });
+ it("should update a section on SECTION_REGISTER if it already exists", () => {
+ const NEW_TITLE = "New Title";
+ const action = {
+ type: at.SECTION_REGISTER,
+ data: { id: "foo_bar_2", title: NEW_TITLE },
+ };
+ const newState = Sections(oldState, action);
+ assert.lengthOf(newState, 5);
+ const updatedSection = newState.find(
+ section => section.id === "foo_bar_2"
+ );
+ assert.ok(updatedSection && updatedSection.title === NEW_TITLE);
+ });
+ it("should set initialized to false on SECTION_REGISTER if there are no rows", () => {
+ const NEW_TITLE = "New Title";
+ const action = {
+ type: at.SECTION_REGISTER,
+ data: { id: "bloop", title: NEW_TITLE },
+ };
+ const newState = Sections(oldState, action);
+ const updatedSection = newState.find(section => section.id === "bloop");
+ assert.propertyVal(updatedSection, "initialized", false);
+ });
+ it("should set initialized to true on SECTION_REGISTER if there are rows", () => {
+ const NEW_TITLE = "New Title";
+ const action = {
+ type: at.SECTION_REGISTER,
+ data: { id: "bloop", title: NEW_TITLE, rows: [{}, {}] },
+ };
+ const newState = Sections(oldState, action);
+ const updatedSection = newState.find(section => section.id === "bloop");
+ assert.propertyVal(updatedSection, "initialized", true);
+ });
+ it("should have no effect on SECTION_UPDATE if the id doesn't exist", () => {
+ const action = {
+ type: at.SECTION_UPDATE,
+ data: { id: "fake_id", data: "fake_data" },
+ };
+ const newState = Sections(oldState, action);
+ assert.deepEqual(oldState, newState);
+ });
+ it("should update the section with the correct data on SECTION_UPDATE", () => {
+ const FAKE_DATA = { rows: ["some", "fake", "data"], foo: "bar" };
+ const action = {
+ type: at.SECTION_UPDATE,
+ data: Object.assign(FAKE_DATA, { id: "foo_bar_2" }),
+ };
+ const newState = Sections(oldState, action);
+ const updatedSection = newState.find(
+ section => section.id === "foo_bar_2"
+ );
+ assert.include(updatedSection, FAKE_DATA);
+ });
+ it("should set initialized to true on SECTION_UPDATE if rows is defined on action.data", () => {
+ const data = { rows: [], id: "foo_bar_2" };
+ const action = { type: at.SECTION_UPDATE, data };
+ const newState = Sections(oldState, action);
+ const updatedSection = newState.find(
+ section => section.id === "foo_bar_2"
+ );
+ assert.propertyVal(updatedSection, "initialized", true);
+ });
+ it("should retain pinned cards on SECTION_UPDATE", () => {
+ const ROW = { id: "row" };
+ let newState = Sections(oldState, {
+ type: at.SECTION_UPDATE,
+ data: Object.assign({ rows: [ROW] }, { id: "foo_bar_2" }),
+ });
+ let updatedSection = newState.find(section => section.id === "foo_bar_2");
+ assert.deepEqual(updatedSection.rows, [ROW]);
+
+ const PINNED_ROW = { id: "pinned", pinned: true, guid: "pinned" };
+ newState = Sections(newState, {
+ type: at.SECTION_UPDATE,
+ data: Object.assign({ rows: [PINNED_ROW] }, { id: "foo_bar_2" }),
+ });
+ updatedSection = newState.find(section => section.id === "foo_bar_2");
+ assert.deepEqual(updatedSection.rows, [PINNED_ROW]);
+
+ // Updating the section again should not duplicate pinned cards
+ newState = Sections(newState, {
+ type: at.SECTION_UPDATE,
+ data: Object.assign({ rows: [PINNED_ROW] }, { id: "foo_bar_2" }),
+ });
+ updatedSection = newState.find(section => section.id === "foo_bar_2");
+ assert.deepEqual(updatedSection.rows, [PINNED_ROW]);
+
+ // Updating the section should retain pinned card at its index
+ newState = Sections(newState, {
+ type: at.SECTION_UPDATE,
+ data: Object.assign({ rows: [ROW] }, { id: "foo_bar_2" }),
+ });
+ updatedSection = newState.find(section => section.id === "foo_bar_2");
+ assert.deepEqual(updatedSection.rows, [PINNED_ROW, ROW]);
+
+ // Clearing/Resetting the section should clear pinned cards
+ newState = Sections(newState, {
+ type: at.SECTION_UPDATE,
+ data: Object.assign({ rows: [] }, { id: "foo_bar_2" }),
+ });
+ updatedSection = newState.find(section => section.id === "foo_bar_2");
+ assert.deepEqual(updatedSection.rows, []);
+ });
+ it("should have no effect on SECTION_UPDATE_CARD if the id or url doesn't exist", () => {
+ const noIdAction = {
+ type: at.SECTION_UPDATE_CARD,
+ data: {
+ id: "non-existent",
+ url: "www.foo.bar",
+ options: { title: "New title" },
+ },
+ };
+ const noIdState = Sections(oldState, noIdAction);
+ const noUrlAction = {
+ type: at.SECTION_UPDATE_CARD,
+ data: {
+ id: "foo_bar_2",
+ url: "www.non-existent.url",
+ options: { title: "New title" },
+ },
+ };
+ const noUrlState = Sections(oldState, noUrlAction);
+ assert.deepEqual(noIdState, oldState);
+ assert.deepEqual(noUrlState, oldState);
+ });
+ it("should update the card with the correct data on SECTION_UPDATE_CARD", () => {
+ const action = {
+ type: at.SECTION_UPDATE_CARD,
+ data: {
+ id: "foo_bar_2",
+ url: "www.other.url",
+ options: { title: "Fake new title" },
+ },
+ };
+ const newState = Sections(oldState, action);
+ const updatedSection = newState.find(
+ section => section.id === "foo_bar_2"
+ );
+ const updatedCard = updatedSection.rows.find(
+ card => card.url === "www.other.url"
+ );
+ assert.propertyVal(updatedCard, "title", "Fake new title");
+ });
+ it("should only update the cards belonging to the right section on SECTION_UPDATE_CARD", () => {
+ const action = {
+ type: at.SECTION_UPDATE_CARD,
+ data: {
+ id: "foo_bar_2",
+ url: "www.other.url",
+ options: { title: "Fake new title" },
+ },
+ };
+ const newState = Sections(oldState, action);
+ newState.forEach((section, i) => {
+ if (section.id !== "foo_bar_2") {
+ assert.deepEqual(section, oldState[i]);
+ }
+ });
+ });
+ it("should allow action.data to set .initialized", () => {
+ const data = { rows: [], initialized: false, id: "foo_bar_2" };
+ const action = { type: at.SECTION_UPDATE, data };
+ const newState = Sections(oldState, action);
+ const updatedSection = newState.find(
+ section => section.id === "foo_bar_2"
+ );
+ assert.propertyVal(updatedSection, "initialized", false);
+ });
+ it("should dedupe based on dedupeConfigurations", () => {
+ const site = { url: "foo.com" };
+ const highlights = { rows: [site], id: "highlights" };
+ const topstories = { rows: [site], id: "topstories" };
+ const dedupeConfigurations = [
+ { id: "topstories", dedupeFrom: ["highlights"] },
+ ];
+ const action = { data: { dedupeConfigurations }, type: "SECTION_UPDATE" };
+ const state = [highlights, topstories];
+
+ const nextState = Sections(state, action);
+
+ assert.equal(nextState.find(s => s.id === "highlights").rows.length, 1);
+ assert.equal(nextState.find(s => s.id === "topstories").rows.length, 0);
+ });
+ it("should remove blocked and deleted urls from all rows in all sections", () => {
+ const blockAction = {
+ type: at.PLACES_LINK_BLOCKED,
+ data: { url: "www.foo.bar" },
+ };
+ const deleteAction = {
+ type: at.PLACES_LINKS_DELETED,
+ data: { urls: ["www.foo.bar"] },
+ };
+ const newBlockState = Sections(oldState, blockAction);
+ const newDeleteState = Sections(oldState, deleteAction);
+ newBlockState.concat(newDeleteState).forEach(section => {
+ assert.deepEqual(section.rows, [{ url: "www.other.url" }]);
+ });
+ });
+ it("should not update state for empty action.data on PLACES_LINK_BLOCKED", () => {
+ const nextState = Sections(undefined, { type: at.PLACES_LINK_BLOCKED });
+ assert.equal(nextState, INITIAL_STATE.Sections);
+ });
+ it("should not update state for empty action.data on PLACES_LINKS_DELETED", () => {
+ const nextState = Sections(undefined, { type: at.PLACES_LINKS_DELETED });
+ assert.equal(nextState, INITIAL_STATE.Sections);
+ });
+ it("should remove all removed pocket urls", () => {
+ const removeAction = {
+ type: at.DELETE_FROM_POCKET,
+ data: { pocket_id: 123 },
+ };
+ const newBlockState = Sections(oldState, removeAction);
+ newBlockState.forEach(section => {
+ assert.deepEqual(section.rows, [{ url: "www.other.url" }]);
+ });
+ });
+ it("should archive all archived pocket urls", () => {
+ const removeAction = {
+ type: at.ARCHIVE_FROM_POCKET,
+ data: { pocket_id: 123 },
+ };
+ const newBlockState = Sections(oldState, removeAction);
+ newBlockState.forEach(section => {
+ assert.deepEqual(section.rows, [{ url: "www.other.url" }]);
+ });
+ });
+ it("should not update state for empty action.data on PLACES_BOOKMARK_ADDED", () => {
+ const nextState = Sections(undefined, { type: at.PLACES_BOOKMARK_ADDED });
+ assert.equal(nextState, INITIAL_STATE.Sections);
+ });
+ it("should bookmark an item when PLACES_BOOKMARK_ADDED is received", () => {
+ const action = {
+ type: at.PLACES_BOOKMARK_ADDED,
+ data: {
+ url: "www.foo.bar",
+ bookmarkGuid: "bookmark123",
+ bookmarkTitle: "Title for bar.com",
+ dateAdded: 1234567,
+ },
+ };
+ const nextState = Sections(oldState, action);
+ // check a section to ensure the correct url was bookmarked
+ const [newRow, oldRow] = nextState[0].rows;
+
+ // new row has bookmark data
+ assert.equal(newRow.url, action.data.url);
+ assert.equal(newRow.type, "bookmark");
+ assert.equal(newRow.bookmarkGuid, action.data.bookmarkGuid);
+ assert.equal(newRow.bookmarkTitle, action.data.bookmarkTitle);
+ assert.equal(newRow.bookmarkDateCreated, action.data.dateAdded);
+
+ // old row is unchanged
+ assert.equal(oldRow, oldState[0].rows[1]);
+ });
+ it("should not update state for empty action.data on PLACES_BOOKMARKS_REMOVED", () => {
+ const nextState = Sections(undefined, {
+ type: at.PLACES_BOOKMARKS_REMOVED,
+ });
+ assert.equal(nextState, INITIAL_STATE.Sections);
+ });
+ it("should remove the bookmark when PLACES_BOOKMARKS_REMOVED is received", () => {
+ const action = {
+ type: at.PLACES_BOOKMARKS_REMOVED,
+ data: {
+ urls: ["www.foo.bar"],
+ bookmarkGuid: "bookmark123",
+ },
+ };
+ // add some bookmark data for the first url in rows
+ oldState.forEach(item => {
+ item.rows[0].bookmarkGuid = "bookmark123";
+ item.rows[0].bookmarkTitle = "Title for bar.com";
+ item.rows[0].bookmarkDateCreated = 1234567;
+ item.rows[0].type = "bookmark";
+ });
+ const nextState = Sections(oldState, action);
+ // check a section to ensure the correct bookmark was removed
+ const [newRow, oldRow] = nextState[0].rows;
+
+ // new row isn't a bookmark
+ assert.equal(newRow.url, action.data.urls[0]);
+ assert.equal(newRow.type, "history");
+ assert.isUndefined(newRow.bookmarkGuid);
+ assert.isUndefined(newRow.bookmarkTitle);
+ assert.isUndefined(newRow.bookmarkDateCreated);
+
+ // old row is unchanged
+ assert.equal(oldRow, oldState[0].rows[1]);
+ });
+ it("should not update state for empty action.data on PLACES_SAVED_TO_POCKET", () => {
+ const nextState = Sections(undefined, {
+ type: at.PLACES_SAVED_TO_POCKET,
+ });
+ assert.equal(nextState, INITIAL_STATE.Sections);
+ });
+ it("should add a pocked item on PLACES_SAVED_TO_POCKET", () => {
+ const action = {
+ type: at.PLACES_SAVED_TO_POCKET,
+ data: {
+ url: "www.foo.bar",
+ pocket_id: 1234,
+ title: "Title for bar.com",
+ },
+ };
+ const nextState = Sections(oldState, action);
+ // check a section to ensure the correct url was saved to pocket
+ const [newRow, oldRow] = nextState[0].rows;
+
+ // new row has pocket data
+ assert.equal(newRow.url, action.data.url);
+ assert.equal(newRow.type, "pocket");
+ assert.equal(newRow.pocket_id, action.data.pocket_id);
+ assert.equal(newRow.title, action.data.title);
+
+ // old row is unchanged
+ assert.equal(oldRow, oldState[0].rows[1]);
+ });
+ it("should remove all content on SNIPPETS_PREVIEW_MODE", () => {
+ const previewMode = { type: at.SNIPPETS_PREVIEW_MODE };
+ const newState = Sections(oldState, previewMode);
+ newState.forEach(section => {
+ assert.lengthOf(section.rows, 0);
+ });
+ });
+ });
+ describe("#insertPinned", () => {
+ let links;
+
+ beforeEach(() => {
+ links = new Array(12).fill(null).map((v, i) => ({ url: `site${i}.com` }));
+ });
+
+ it("should place pinned links where they belong", () => {
+ const pinned = [
+ { url: "http://github.com/mozilla/activity-stream", title: "moz/a-s" },
+ { url: "http://example.com", title: "example" },
+ ];
+ const result = insertPinned(links, pinned);
+ for (let index of [0, 1]) {
+ assert.equal(result[index].url, pinned[index].url);
+ assert.ok(result[index].isPinned);
+ assert.equal(result[index].pinIndex, index);
+ }
+ assert.deepEqual(result.slice(2), links);
+ });
+ it("should handle empty slots in the pinned list", () => {
+ const pinned = [
+ null,
+ { url: "http://github.com/mozilla/activity-stream", title: "moz/a-s" },
+ null,
+ null,
+ { url: "http://example.com", title: "example" },
+ ];
+ const result = insertPinned(links, pinned);
+ for (let index of [1, 4]) {
+ assert.equal(result[index].url, pinned[index].url);
+ assert.ok(result[index].isPinned);
+ assert.equal(result[index].pinIndex, index);
+ }
+ result.splice(4, 1);
+ result.splice(1, 1);
+ assert.deepEqual(result, links);
+ });
+ it("should handle a pinned site past the end of the list of links", () => {
+ const pinned = [];
+ pinned[11] = {
+ url: "http://github.com/mozilla/activity-stream",
+ title: "moz/a-s",
+ };
+ const result = insertPinned([], pinned);
+ assert.equal(result[11].url, pinned[11].url);
+ assert.isTrue(result[11].isPinned);
+ assert.equal(result[11].pinIndex, 11);
+ });
+ it("should unpin previously pinned links no longer in the pinned list", () => {
+ const pinned = [];
+ links[2].isPinned = true;
+ links[2].pinIndex = 2;
+ const result = insertPinned(links, pinned);
+ assert.notProperty(result[2], "isPinned");
+ assert.notProperty(result[2], "pinIndex");
+ });
+ it("should handle a link present in both the links and pinned list", () => {
+ const pinned = [links[7]];
+ const result = insertPinned(links, pinned);
+ assert.equal(links.length, result.length);
+ });
+ it("should not modify the original data", () => {
+ const pinned = [{ url: "http://example.com" }];
+
+ insertPinned(links, pinned);
+
+ assert.equal(typeof pinned[0].isPinned, "undefined");
+ });
+ });
+ describe("Snippets", () => {
+ it("should return INITIAL_STATE by default", () => {
+ assert.equal(
+ Snippets(undefined, { type: "some_action" }),
+ INITIAL_STATE.Snippets
+ );
+ });
+ it("should set initialized to true on a SNIPPETS_DATA action", () => {
+ const state = Snippets(undefined, { type: at.SNIPPETS_DATA, data: {} });
+ assert.isTrue(state.initialized);
+ });
+ it("should set the snippet data on a SNIPPETS_DATA action", () => {
+ const data = { snippetsURL: "foo.com", version: 4 };
+ const state = Snippets(undefined, { type: at.SNIPPETS_DATA, data });
+ assert.propertyVal(state, "snippetsURL", data.snippetsURL);
+ assert.propertyVal(state, "version", data.version);
+ });
+ it("should reset to the initial state on a SNIPPETS_RESET action", () => {
+ const state = Snippets(
+ { initialized: true, foo: "bar" },
+ { type: at.SNIPPETS_RESET }
+ );
+ assert.equal(state, INITIAL_STATE.Snippets);
+ });
+ it("should set the new blocklist on SNIPPET_BLOCKED", () => {
+ const state = Snippets(
+ { blockList: [] },
+ { type: at.SNIPPET_BLOCKED, data: 1 }
+ );
+ assert.deepEqual(state.blockList, [1]);
+ });
+ it("should clear the blocklist on SNIPPETS_BLOCKLIST_CLEARED", () => {
+ const state = Snippets(
+ { blockList: [1, 2] },
+ { type: at.SNIPPETS_BLOCKLIST_CLEARED }
+ );
+ assert.deepEqual(state.blockList, []);
+ });
+ });
+ describe("Pocket", () => {
+ it("should return INITIAL_STATE by default", () => {
+ assert.equal(
+ Pocket(undefined, { type: "some_action" }),
+ INITIAL_STATE.Pocket
+ );
+ });
+ it("should set waitingForSpoc on a POCKET_WAITING_FOR_SPOC action", () => {
+ const state = Pocket(undefined, {
+ type: at.POCKET_WAITING_FOR_SPOC,
+ data: false,
+ });
+ assert.isFalse(state.waitingForSpoc);
+ });
+ it("should have undefined for initial isUserLoggedIn state", () => {
+ assert.isNull(Pocket(undefined, { type: "some_action" }).isUserLoggedIn);
+ });
+ it("should set isUserLoggedIn to false on a POCKET_LOGGED_IN with null", () => {
+ const state = Pocket(undefined, {
+ type: at.POCKET_LOGGED_IN,
+ data: null,
+ });
+ assert.isFalse(state.isUserLoggedIn);
+ });
+ it("should set isUserLoggedIn to false on a POCKET_LOGGED_IN with false", () => {
+ const state = Pocket(undefined, {
+ type: at.POCKET_LOGGED_IN,
+ data: false,
+ });
+ assert.isFalse(state.isUserLoggedIn);
+ });
+ it("should set isUserLoggedIn to true on a POCKET_LOGGED_IN with true", () => {
+ const state = Pocket(undefined, {
+ type: at.POCKET_LOGGED_IN,
+ data: true,
+ });
+ assert.isTrue(state.isUserLoggedIn);
+ });
+ it("should set pocketCta with correct object on a POCKET_CTA", () => {
+ const data = {
+ cta_button: "cta button",
+ cta_text: "cta text",
+ cta_url: "https://cta-url.com",
+ use_cta: true,
+ };
+ const state = Pocket(undefined, { type: at.POCKET_CTA, data });
+ assert.equal(state.pocketCta.ctaButton, data.cta_button);
+ assert.equal(state.pocketCta.ctaText, data.cta_text);
+ assert.equal(state.pocketCta.ctaUrl, data.cta_url);
+ assert.equal(state.pocketCta.useCta, data.use_cta);
+ });
+ });
+ describe("Personalization", () => {
+ it("should return INITIAL_STATE by default", () => {
+ assert.equal(
+ Personalization(undefined, { type: "some_action" }),
+ INITIAL_STATE.Personalization
+ );
+ });
+ it("should set lastUpdated with DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED", () => {
+ const state = Personalization(undefined, {
+ type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED,
+ data: {
+ lastUpdated: 123,
+ },
+ });
+ assert.equal(state.lastUpdated, 123);
+ });
+ it("should set initialized to true with DISCOVERY_STREAM_PERSONALIZATION_INIT", () => {
+ const state = Personalization(undefined, {
+ type: at.DISCOVERY_STREAM_PERSONALIZATION_INIT,
+ });
+ assert.equal(state.initialized, true);
+ });
+ });
+ describe("DiscoveryStream", () => {
+ it("should return INITIAL_STATE by default", () => {
+ assert.equal(
+ DiscoveryStream(undefined, { type: "some_action" }),
+ INITIAL_STATE.DiscoveryStream
+ );
+ });
+ it("should set isPrivacyInfoModalVisible to true with SHOW_PRIVACY_INFO", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.SHOW_PRIVACY_INFO,
+ });
+ assert.equal(state.isPrivacyInfoModalVisible, true);
+ });
+ it("should set isPrivacyInfoModalVisible to false with HIDE_PRIVACY_INFO", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.HIDE_PRIVACY_INFO,
+ });
+ assert.equal(state.isPrivacyInfoModalVisible, false);
+ });
+ it("should set layout data with DISCOVERY_STREAM_LAYOUT_UPDATE", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: ["test"], lastUpdated: 123 },
+ });
+ assert.equal(state.layout[0], "test");
+ assert.equal(state.lastUpdated, 123);
+ });
+ it("should reset layout data with DISCOVERY_STREAM_LAYOUT_RESET", () => {
+ const layoutData = { layout: ["test"], lastUpdated: 123 };
+ const feedsData = {
+ "https://foo.com/feed1": { lastUpdated: 123, data: [1, 2, 3] },
+ };
+ const spocsData = {
+ lastUpdated: 123,
+ spocs: [1, 2, 3],
+ };
+ let state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: layoutData,
+ });
+ state = DiscoveryStream(state, {
+ type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
+ data: feedsData,
+ });
+ state = DiscoveryStream(state, {
+ type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
+ data: spocsData,
+ });
+ state = DiscoveryStream(state, {
+ type: at.DISCOVERY_STREAM_LAYOUT_RESET,
+ });
+
+ assert.deepEqual(state, INITIAL_STATE.DiscoveryStream);
+ });
+ it("should set config data with DISCOVERY_STREAM_CONFIG_CHANGE", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
+ data: { enabled: true },
+ });
+ assert.deepEqual(state.config, { enabled: true });
+ });
+ it("should set recentSavesEnabled with DISCOVERY_STREAM_PREFS_SETUP", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_PREFS_SETUP,
+ data: { recentSavesEnabled: true },
+ });
+ assert.isTrue(state.recentSavesEnabled);
+ });
+ it("should set recentSavesData with DISCOVERY_STREAM_RECENT_SAVES", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_RECENT_SAVES,
+ data: { recentSaves: [1, 2, 3] },
+ });
+ assert.deepEqual(state.recentSavesData, [1, 2, 3]);
+ });
+ it("should set isUserLoggedIn with DISCOVERY_STREAM_POCKET_STATE_SET", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
+ data: { isUserLoggedIn: true },
+ });
+ assert.isTrue(state.isUserLoggedIn);
+ });
+ it("should set feeds as loaded with DISCOVERY_STREAM_FEEDS_UPDATE", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
+ });
+ assert.isTrue(state.feeds.loaded);
+ });
+ it("should set spoc_endpoint with DISCOVERY_STREAM_SPOCS_ENDPOINT", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
+ data: { url: "foo.com" },
+ });
+ assert.equal(state.spocs.spocs_endpoint, "foo.com");
+ });
+ it("should use initial state with DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
+ data: {},
+ });
+ assert.deepEqual(state.spocs.placements, []);
+ });
+ it("should set placements with DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
+ data: {
+ placements: [1, 2, 3],
+ },
+ });
+ assert.deepEqual(state.spocs.placements, [1, 2, 3]);
+ });
+ it("should set spocs with DISCOVERY_STREAM_SPOCS_UPDATE", () => {
+ const data = {
+ lastUpdated: 123,
+ spocs: [1, 2, 3],
+ };
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
+ data,
+ });
+ assert.deepEqual(state.spocs, {
+ spocs_endpoint: "",
+ data: [1, 2, 3],
+ lastUpdated: 123,
+ loaded: true,
+ frequency_caps: [],
+ blocked: [],
+ placements: [],
+ });
+ });
+ it("should default to a single spoc placement", () => {
+ const deleteAction = {
+ type: at.DISCOVERY_STREAM_LINK_BLOCKED,
+ data: { url: "https://foo.com" },
+ };
+ const oldState = {
+ spocs: {
+ data: {
+ spocs: {
+ items: [
+ {
+ url: "test-spoc.com",
+ },
+ ],
+ },
+ },
+ loaded: true,
+ },
+ feeds: {
+ data: {},
+ loaded: true,
+ },
+ };
+
+ const newState = DiscoveryStream(oldState, deleteAction);
+
+ assert.equal(newState.spocs.data.spocs.items.length, 1);
+ });
+ it("should handle no data from DISCOVERY_STREAM_SPOCS_UPDATE", () => {
+ const data = null;
+ const state = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
+ data,
+ });
+ assert.deepEqual(state.spocs, INITIAL_STATE.DiscoveryStream.spocs);
+ });
+ it("should add blocked spocs to blocked array with DISCOVERY_STREAM_SPOC_BLOCKED", () => {
+ const firstState = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_SPOC_BLOCKED,
+ data: { url: "https://foo.com" },
+ });
+ const secondState = DiscoveryStream(firstState, {
+ type: at.DISCOVERY_STREAM_SPOC_BLOCKED,
+ data: { url: "https://bar.com" },
+ });
+ assert.deepEqual(firstState.spocs.blocked, ["https://foo.com"]);
+ assert.deepEqual(secondState.spocs.blocked, [
+ "https://foo.com",
+ "https://bar.com",
+ ]);
+ });
+ it("should not update state for empty action.data on DISCOVERY_STREAM_LINK_BLOCKED", () => {
+ const newState = DiscoveryStream(undefined, {
+ type: at.DISCOVERY_STREAM_LINK_BLOCKED,
+ });
+ assert.equal(newState, INITIAL_STATE.DiscoveryStream);
+ });
+ it("should not update state if feeds are not loaded", () => {
+ const deleteAction = {
+ type: at.DISCOVERY_STREAM_LINK_BLOCKED,
+ data: { url: "foo.com" },
+ };
+ const newState = DiscoveryStream(undefined, deleteAction);
+ assert.equal(newState, INITIAL_STATE.DiscoveryStream);
+ });
+ it("should not update state if spocs and feeds data is undefined", () => {
+ const deleteAction = {
+ type: at.DISCOVERY_STREAM_LINK_BLOCKED,
+ data: { url: "foo.com" },
+ };
+ const oldState = {
+ spocs: {
+ data: {},
+ loaded: true,
+ placements: [{ name: "spocs" }],
+ },
+ feeds: {
+ data: {},
+ loaded: true,
+ },
+ };
+ const newState = DiscoveryStream(oldState, deleteAction);
+ assert.deepEqual(newState, oldState);
+ });
+ it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from spocs if feeds data is empty", () => {
+ const deleteAction = {
+ type: at.DISCOVERY_STREAM_LINK_BLOCKED,
+ data: { url: "https://foo.com" },
+ };
+ const oldState = {
+ spocs: {
+ data: {
+ spocs: {
+ items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+ },
+ },
+ loaded: true,
+ placements: [{ name: "spocs" }],
+ },
+ feeds: {
+ data: {},
+ loaded: true,
+ },
+ };
+ const newState = DiscoveryStream(oldState, deleteAction);
+ assert.deepEqual(newState.spocs.data.spocs.items, [
+ { url: "test-spoc.com" },
+ ]);
+ });
+ it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from feeds if spocs data is empty", () => {
+ const deleteAction = {
+ type: at.DISCOVERY_STREAM_LINK_BLOCKED,
+ data: { url: "https://foo.com" },
+ };
+ const oldState = {
+ spocs: {
+ data: {},
+ loaded: true,
+ placements: [{ name: "spocs" }],
+ },
+ feeds: {
+ data: {
+ "https://foo.com/feed1": {
+ data: {
+ recommendations: [
+ { url: "https://foo.com" },
+ { url: "test.com" },
+ ],
+ },
+ },
+ },
+ loaded: true,
+ },
+ };
+ const newState = DiscoveryStream(oldState, deleteAction);
+ assert.deepEqual(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations,
+ [{ url: "test.com" }]
+ );
+ });
+ it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from both feeds and spocs", () => {
+ const oldState = {
+ feeds: {
+ data: {
+ "https://foo.com/feed1": {
+ data: {
+ recommendations: [
+ { url: "https://foo.com" },
+ { url: "test.com" },
+ ],
+ },
+ },
+ },
+ loaded: true,
+ },
+ spocs: {
+ data: {
+ spocs: {
+ items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+ },
+ },
+ loaded: true,
+ placements: [{ name: "spocs" }],
+ },
+ };
+ const deleteAction = {
+ type: at.DISCOVERY_STREAM_LINK_BLOCKED,
+ data: { url: "https://foo.com" },
+ };
+ const newState = DiscoveryStream(oldState, deleteAction);
+ assert.deepEqual(newState.spocs.data.spocs.items, [
+ { url: "test-spoc.com" },
+ ]);
+ assert.deepEqual(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations,
+ [{ url: "test.com" }]
+ );
+ });
+ it("should not update state for empty action.data on PLACES_SAVED_TO_POCKET", () => {
+ const newState = DiscoveryStream(undefined, {
+ type: at.PLACES_SAVED_TO_POCKET,
+ });
+ assert.equal(newState, INITIAL_STATE.DiscoveryStream);
+ });
+ it("should add pocket_id on PLACES_SAVED_TO_POCKET in both feeds and spocs", () => {
+ const oldState = {
+ feeds: {
+ data: {
+ "https://foo.com/feed1": {
+ data: {
+ recommendations: [
+ { url: "https://foo.com" },
+ { url: "test.com" },
+ ],
+ },
+ },
+ },
+ loaded: true,
+ },
+ spocs: {
+ data: {
+ spocs: {
+ items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+ },
+ },
+ placements: [{ name: "spocs" }],
+ loaded: true,
+ },
+ };
+ const action = {
+ type: at.PLACES_SAVED_TO_POCKET,
+ data: {
+ url: "https://foo.com",
+ pocket_id: 1234,
+ open_url: "https://foo-1234",
+ },
+ };
+
+ const newState = DiscoveryStream(oldState, action);
+
+ assert.lengthOf(newState.spocs.data.spocs.items, 2);
+ assert.equal(
+ newState.spocs.data.spocs.items[0].pocket_id,
+ action.data.pocket_id
+ );
+ assert.equal(
+ newState.spocs.data.spocs.items[0].open_url,
+ action.data.open_url
+ );
+ assert.isUndefined(newState.spocs.data.spocs.items[1].pocket_id);
+
+ assert.lengthOf(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations,
+ 2
+ );
+ assert.equal(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
+ .pocket_id,
+ action.data.pocket_id
+ );
+ assert.equal(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
+ .open_url,
+ action.data.open_url
+ );
+ assert.isUndefined(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations[1]
+ .pocket_id
+ );
+ });
+ it("should not update state for empty action.data on DELETE_FROM_POCKET", () => {
+ const newState = DiscoveryStream(undefined, {
+ type: at.DELETE_FROM_POCKET,
+ });
+ assert.equal(newState, INITIAL_STATE.DiscoveryStream);
+ });
+ it("should remove site on DELETE_FROM_POCKET in both feeds and spocs", () => {
+ const oldState = {
+ feeds: {
+ data: {
+ "https://foo.com/feed1": {
+ data: {
+ recommendations: [
+ { url: "https://foo.com", pocket_id: 1234 },
+ { url: "test.com" },
+ ],
+ },
+ },
+ },
+ loaded: true,
+ },
+ spocs: {
+ data: {
+ spocs: {
+ items: [
+ { url: "https://foo.com", pocket_id: 1234 },
+ { url: "test-spoc.com" },
+ ],
+ },
+ },
+ loaded: true,
+ placements: [{ name: "spocs" }],
+ },
+ };
+ const deleteAction = {
+ type: at.DELETE_FROM_POCKET,
+ data: {
+ pocket_id: 1234,
+ },
+ };
+
+ const newState = DiscoveryStream(oldState, deleteAction);
+ assert.deepEqual(newState.spocs.data.spocs.items, [
+ { url: "test-spoc.com" },
+ ]);
+ assert.deepEqual(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations,
+ [{ url: "test.com" }]
+ );
+ });
+ it("should remove site on ARCHIVE_FROM_POCKET in both feeds and spocs", () => {
+ const oldState = {
+ feeds: {
+ data: {
+ "https://foo.com/feed1": {
+ data: {
+ recommendations: [
+ { url: "https://foo.com", pocket_id: 1234 },
+ { url: "test.com" },
+ ],
+ },
+ },
+ },
+ loaded: true,
+ },
+ spocs: {
+ data: {
+ spocs: {
+ items: [
+ { url: "https://foo.com", pocket_id: 1234 },
+ { url: "test-spoc.com" },
+ ],
+ },
+ },
+ loaded: true,
+ placements: [{ name: "spocs" }],
+ },
+ };
+ const deleteAction = {
+ type: at.ARCHIVE_FROM_POCKET,
+ data: {
+ pocket_id: 1234,
+ },
+ };
+
+ const newState = DiscoveryStream(oldState, deleteAction);
+ assert.deepEqual(newState.spocs.data.spocs.items, [
+ { url: "test-spoc.com" },
+ ]);
+ assert.deepEqual(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations,
+ [{ url: "test.com" }]
+ );
+ });
+ it("should add boookmark details on PLACES_BOOKMARK_ADDED in both feeds and spocs", () => {
+ const oldState = {
+ feeds: {
+ data: {
+ "https://foo.com/feed1": {
+ data: {
+ recommendations: [
+ { url: "https://foo.com" },
+ { url: "test.com" },
+ ],
+ },
+ },
+ },
+ loaded: true,
+ },
+ spocs: {
+ data: {
+ spocs: {
+ items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }],
+ },
+ },
+ loaded: true,
+ placements: [{ name: "spocs" }],
+ },
+ };
+ const bookmarkAction = {
+ type: at.PLACES_BOOKMARK_ADDED,
+ data: {
+ url: "https://foo.com",
+ bookmarkGuid: "bookmark123",
+ bookmarkTitle: "Title for bar.com",
+ dateAdded: 1234567,
+ },
+ };
+
+ const newState = DiscoveryStream(oldState, bookmarkAction);
+
+ assert.lengthOf(newState.spocs.data.spocs.items, 2);
+ assert.equal(
+ newState.spocs.data.spocs.items[0].bookmarkGuid,
+ bookmarkAction.data.bookmarkGuid
+ );
+ assert.equal(
+ newState.spocs.data.spocs.items[0].bookmarkTitle,
+ bookmarkAction.data.bookmarkTitle
+ );
+ assert.isUndefined(newState.spocs.data.spocs.items[1].bookmarkGuid);
+
+ assert.lengthOf(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations,
+ 2
+ );
+ assert.equal(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
+ .bookmarkGuid,
+ bookmarkAction.data.bookmarkGuid
+ );
+ assert.equal(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
+ .bookmarkTitle,
+ bookmarkAction.data.bookmarkTitle
+ );
+ assert.isUndefined(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations[1]
+ .bookmarkGuid
+ );
+ });
+
+ it("should remove boookmark details on PLACES_BOOKMARKS_REMOVED in both feeds and spocs", () => {
+ const oldState = {
+ feeds: {
+ data: {
+ "https://foo.com/feed1": {
+ data: {
+ recommendations: [
+ {
+ url: "https://foo.com",
+ bookmarkGuid: "bookmark123",
+ bookmarkTitle: "Title for bar.com",
+ },
+ { url: "test.com" },
+ ],
+ },
+ },
+ },
+ loaded: true,
+ },
+ spocs: {
+ data: {
+ spocs: {
+ items: [
+ {
+ url: "https://foo.com",
+ bookmarkGuid: "bookmark123",
+ bookmarkTitle: "Title for bar.com",
+ },
+ { url: "test-spoc.com" },
+ ],
+ },
+ },
+ loaded: true,
+ placements: [{ name: "spocs" }],
+ },
+ };
+ const action = {
+ type: at.PLACES_BOOKMARKS_REMOVED,
+ data: {
+ urls: ["https://foo.com"],
+ },
+ };
+
+ const newState = DiscoveryStream(oldState, action);
+
+ assert.lengthOf(newState.spocs.data.spocs.items, 2);
+ assert.isUndefined(newState.spocs.data.spocs.items[0].bookmarkGuid);
+ assert.isUndefined(newState.spocs.data.spocs.items[0].bookmarkTitle);
+
+ assert.lengthOf(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations,
+ 2
+ );
+ assert.isUndefined(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
+ .bookmarkGuid
+ );
+ assert.isUndefined(
+ newState.feeds.data["https://foo.com/feed1"].data.recommendations[0]
+ .bookmarkTitle
+ );
+ });
+ describe("PREF_CHANGED", () => {
+ it("should set isCollectionDismissible", () => {
+ const state = DiscoveryStream(undefined, {
+ type: at.PREF_CHANGED,
+ data: {
+ name: "discoverystream.isCollectionDismissible",
+ value: true,
+ },
+ });
+ assert.equal(state.isCollectionDismissible, true);
+ });
+ });
+ });
+ describe("Search", () => {
+ it("should return INITIAL_STATE by default", () => {
+ assert.equal(
+ Search(undefined, { type: "some_action" }),
+ INITIAL_STATE.Search
+ );
+ });
+ it("should set disable to true on DISABLE_SEARCH", () => {
+ const nextState = Search(undefined, { type: "DISABLE_SEARCH" });
+ assert.propertyVal(nextState, "disable", true);
+ });
+ it("should set focus to true on FAKE_FOCUS_SEARCH", () => {
+ const nextState = Search(undefined, { type: "FAKE_FOCUS_SEARCH" });
+ assert.propertyVal(nextState, "fakeFocus", true);
+ });
+ it("should set focus and disable to false on SHOW_SEARCH", () => {
+ const nextState = Search(undefined, { type: "SHOW_SEARCH" });
+ assert.propertyVal(nextState, "fakeFocus", false);
+ assert.propertyVal(nextState, "disable", false);
+ });
+ });
+ it("should set initialized to true on AS_ROUTER_INITIALIZED", () => {
+ const nextState = ASRouter(undefined, { type: "AS_ROUTER_INITIALIZED" });
+ assert.propertyVal(nextState, "initialized", true);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx b/browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx
new file mode 100644
index 0000000000..1bd01fb220
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx
@@ -0,0 +1,516 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import {
+ ASRouterAdminInner,
+ CollapseToggle,
+ DiscoveryStreamAdmin,
+ Personalization,
+ ToggleStoryButton,
+} from "content-src/components/ASRouterAdmin/ASRouterAdmin";
+import { ASRouterUtils } from "content-src/asrouter/asrouter-utils";
+import { GlobalOverrider } from "test/unit/utils";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("ASRouterAdmin", () => {
+ let globalOverrider;
+ let sandbox;
+ let wrapper;
+ let globals;
+ let FAKE_PROVIDER_PREF = [
+ {
+ enabled: true,
+ id: "snippets_local_testing",
+ localProvider: "SnippetsProvider",
+ type: "local",
+ },
+ ];
+ let FAKE_PROVIDER = [
+ {
+ enabled: true,
+ id: "snippets_local_testing",
+ localProvider: "SnippetsProvider",
+ messages: [],
+ type: "local",
+ },
+ ];
+ beforeEach(() => {
+ globalOverrider = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ sandbox.stub(ASRouterUtils, "getPreviewEndpoint").returns("foo");
+ globals = {
+ ASRouterMessage: sandbox.stub().resolves(),
+ ASRouterAddParentListener: sandbox.stub(),
+ ASRouterRemoveParentListener: sandbox.stub(),
+ };
+ globalOverrider.set(globals);
+ wrapper = shallow(
+ <ASRouterAdminInner collapsed={false} location={{ routes: [""] }} />
+ );
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globalOverrider.restore();
+ });
+ it("should render ASRouterAdmin component", () => {
+ assert.ok(wrapper.exists());
+ });
+ it("should send ADMIN_CONNECT_STATE on mount", () => {
+ assert.calledOnce(globals.ASRouterMessage);
+ assert.calledWith(globals.ASRouterMessage, {
+ type: "ADMIN_CONNECT_STATE",
+ data: { endpoint: "foo" },
+ });
+ });
+ it("should set a .collapsed class on the outer div if props.collapsed is true", () => {
+ wrapper.setProps({ collapsed: true });
+ assert.isTrue(wrapper.find(".asrouter-admin").hasClass("collapsed"));
+ });
+ it("should set a .expanded class on the outer div if props.collapsed is false", () => {
+ wrapper.setProps({ collapsed: false });
+ assert.isTrue(wrapper.find(".asrouter-admin").hasClass("expanded"));
+ assert.isFalse(wrapper.find(".asrouter-admin").hasClass("collapsed"));
+ });
+ describe("#getSection", () => {
+ it("should render a message provider section by default", () => {
+ assert.equal(wrapper.find("h2").at(1).text(), "Messages");
+ });
+ it("should render a targeting section for targeting route", () => {
+ wrapper = shallow(
+ <ASRouterAdminInner location={{ routes: ["targeting"] }} />
+ );
+ assert.equal(wrapper.find("h2").at(0).text(), "Targeting Utilities");
+ });
+ it("should render a DS section for DS route", () => {
+ wrapper = shallow(
+ <ASRouterAdminInner
+ location={{ routes: ["ds"] }}
+ Sections={[]}
+ Prefs={{}}
+ />
+ );
+ assert.equal(wrapper.find("h2").at(0).text(), "Discovery Stream");
+ });
+ it("should render two error messages", () => {
+ wrapper = shallow(
+ <ASRouterAdminInner location={{ routes: ["errors"] }} Sections={[]} />
+ );
+ const firstError = {
+ timestamp: Date.now() + 100,
+ error: { message: "first" },
+ };
+ const secondError = {
+ timestamp: Date.now(),
+ error: { message: "second" },
+ };
+ wrapper.setState({
+ providers: [{ id: "foo", errors: [firstError, secondError] }],
+ });
+
+ assert.equal(
+ wrapper.find("tbody tr").at(0).find("td").at(0).text(),
+ "foo"
+ );
+ assert.lengthOf(wrapper.find("tbody tr"), 2);
+ assert.equal(
+ wrapper.find("tbody tr").at(0).find("td").at(1).text(),
+ secondError.error.message
+ );
+ });
+ });
+ describe("#render", () => {
+ beforeEach(() => {
+ wrapper.setState({
+ providerPrefs: [],
+ providers: [],
+ userPrefs: {},
+ });
+ });
+ describe("#renderProviders", () => {
+ it("should render the provider", () => {
+ wrapper.setState({
+ providerPrefs: FAKE_PROVIDER_PREF,
+ providers: FAKE_PROVIDER,
+ });
+
+ // Header + 1 item
+ assert.lengthOf(wrapper.find(".message-item"), 2);
+ });
+ });
+ describe("#renderMessages", () => {
+ beforeEach(() => {
+ sandbox.stub(ASRouterUtils, "blockById").resolves();
+ sandbox.stub(ASRouterUtils, "unblockById").resolves();
+ sandbox.stub(ASRouterUtils, "overrideMessage").resolves({ foo: "bar" });
+ sandbox.stub(ASRouterUtils, "sendMessage").resolves();
+ wrapper.setState({
+ messageFilter: "all",
+ messageBlockList: [],
+ messageImpressions: { foo: 2 },
+ groups: [{ id: "messageProvider", enabled: true }],
+ providers: [{ id: "messageProvider", enabled: true }],
+ });
+ });
+ it("should render a message when no filtering is applied", () => {
+ wrapper.setState({
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".message-id"), 1);
+ wrapper.find(".message-item button.primary").simulate("click");
+ assert.calledOnce(ASRouterUtils.blockById);
+ assert.calledWith(ASRouterUtils.blockById, "foo");
+ });
+ it("should render a blocked message", () => {
+ wrapper.setState({
+ messages: [
+ {
+ id: "foo",
+ groups: ["messageProvider"],
+ provider: "messageProvider",
+ },
+ ],
+ messageBlockList: ["foo"],
+ });
+ assert.lengthOf(wrapper.find(".message-item.blocked"), 1);
+ wrapper.find(".message-item.blocked button").simulate("click");
+ assert.calledOnce(ASRouterUtils.unblockById);
+ assert.calledWith(ASRouterUtils.unblockById, "foo");
+ });
+ it("should render a message if provider matches filter", () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".message-id"), 1);
+ });
+ it("should override with the selected message", async () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".message-id"), 1);
+ wrapper.find(".message-item button.show").simulate("click");
+ assert.calledOnce(ASRouterUtils.overrideMessage);
+ assert.calledWith(ASRouterUtils.overrideMessage, "foo");
+ await ASRouterUtils.overrideMessage();
+ assert.equal(wrapper.state().foo, "bar");
+ });
+ it("should hide message if provider filter changes", () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".message-id"), 1);
+
+ wrapper.find("select").simulate("change", { target: { value: "bar" } });
+
+ assert.lengthOf(wrapper.find(".message-id"), 0);
+ });
+ it("should not display Reset All button if provider filter value is set to all or test providers", () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find(".messages-reset"), 1);
+ wrapper.find("select").simulate("change", { target: { value: "all" } });
+
+ assert.lengthOf(wrapper.find(".messages-reset"), 0);
+
+ wrapper
+ .find("select")
+ .simulate("change", { target: { value: "test_local_testing" } });
+ assert.lengthOf(wrapper.find(".messages-reset"), 0);
+ });
+ it("should trigger disable and enable provider on Reset All button click", () => {
+ wrapper.setState({
+ messageFilter: "messageProvider",
+ messages: [
+ {
+ id: "foo",
+ provider: "messageProvider",
+ groups: ["messageProvider"],
+ },
+ ],
+ providerPrefs: [
+ {
+ id: "messageProvider",
+ },
+ ],
+ });
+ wrapper.find(".messages-reset").simulate("click");
+ assert.calledTwice(ASRouterUtils.sendMessage);
+ assert.calledWith(ASRouterUtils.sendMessage, {
+ type: "DISABLE_PROVIDER",
+ data: "messageProvider",
+ });
+ assert.calledWith(ASRouterUtils.sendMessage, {
+ type: "ENABLE_PROVIDER",
+ data: "messageProvider",
+ });
+ });
+ });
+ });
+ describe("#DiscoveryStream", () => {
+ let state = {};
+ let dispatch;
+ beforeEach(() => {
+ dispatch = sandbox.stub();
+ state = {
+ config: {
+ enabled: true,
+ layout_endpoint: "",
+ },
+ layout: [],
+ spocs: {
+ frequency_caps: [],
+ },
+ feeds: {
+ data: {},
+ },
+ };
+ wrapper = shallow(
+ <DiscoveryStreamAdmin
+ dispatch={dispatch}
+ otherPrefs={{}}
+ state={{
+ DiscoveryStream: state,
+ }}
+ />
+ );
+ });
+ it("should render a DiscoveryStreamAdmin component", () => {
+ assert.equal(wrapper.find("h3").at(0).text(), "Endpoint variant");
+ });
+ it("should render a spoc in DiscoveryStreamAdmin component", () => {
+ state.spocs = {
+ frequency_caps: [],
+ data: {
+ spocs: {
+ items: [
+ {
+ id: 12345,
+ },
+ ],
+ },
+ },
+ };
+ wrapper = shallow(
+ <DiscoveryStreamAdmin
+ otherPrefs={{}}
+ state={{ DiscoveryStream: state }}
+ />
+ );
+ wrapper.instance().onStoryToggle({ id: 12345 });
+ const messageSummary = wrapper.find(".message-summary").at(0);
+ const pre = messageSummary.find("pre").at(0);
+ const spocText = pre.text();
+ assert.equal(spocText, '{\n "id": 12345\n}');
+ });
+ it("should fire restorePrefDefaults with DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", () => {
+ wrapper.find("button").at(0).simulate("click");
+ assert.calledWith(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS,
+ })
+ );
+ });
+ it("should fire config change with DISCOVERY_STREAM_CONFIG_CHANGE", () => {
+ wrapper.find("button").at(1).simulate("click");
+ assert.calledWith(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
+ data: { enabled: true, layout_endpoint: "" },
+ })
+ );
+ });
+ it("should fire expireCache with DISCOVERY_STREAM_DEV_EXPIRE_CACHE", () => {
+ wrapper.find("button").at(2).simulate("click");
+ assert.calledWith(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE,
+ })
+ );
+ });
+ it("should fire systemTick with DISCOVERY_STREAM_DEV_SYSTEM_TICK", () => {
+ wrapper.find("button").at(3).simulate("click");
+ assert.calledWith(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_DEV_SYSTEM_TICK,
+ })
+ );
+ });
+ it("should fire idleDaily with DISCOVERY_STREAM_DEV_IDLE_DAILY", () => {
+ wrapper.find("button").at(4).simulate("click");
+ assert.calledWith(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_DEV_IDLE_DAILY,
+ })
+ );
+ });
+ it("should fire syncRemoteSettings with DISCOVERY_STREAM_DEV_SYNC_RS", () => {
+ wrapper.find("button").at(5).simulate("click");
+ assert.calledWith(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_DEV_SYNC_RS,
+ })
+ );
+ });
+ it("should fire setConfigValue with DISCOVERY_STREAM_CONFIG_SET_VALUE", () => {
+ const name = "name";
+ const value = "value";
+ wrapper.instance().setConfigValue(name, value);
+ assert.calledWith(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE,
+ data: { name, value },
+ })
+ );
+ });
+ });
+
+ describe("#Personalization", () => {
+ let dispatch;
+ beforeEach(() => {
+ dispatch = sandbox.stub();
+ wrapper = shallow(
+ <Personalization
+ dispatch={dispatch}
+ state={{
+ Personalization: {
+ lastUpdated: 1000,
+ initialized: true,
+ },
+ }}
+ />
+ );
+ });
+ it("should render with pref checkbox, lastUpdated, and initialized", () => {
+ assert.lengthOf(wrapper.find("TogglePrefCheckbox"), 1);
+ assert.equal(
+ wrapper.find("td").at(1).text(),
+ "Personalization Last Updated"
+ );
+ assert.equal(
+ wrapper.find("td").at(2).text(),
+ new Date(1000).toLocaleString()
+ );
+ assert.equal(
+ wrapper.find("td").at(3).text(),
+ "Personalization Initialized"
+ );
+ assert.equal(wrapper.find("td").at(4).text(), "true");
+ });
+ it("should render with no data with no last updated", () => {
+ wrapper = shallow(
+ <Personalization
+ dispatch={dispatch}
+ state={{
+ Personalization: {
+ version: 2,
+ lastUpdated: 0,
+ initialized: true,
+ },
+ }}
+ />
+ );
+ assert.equal(wrapper.find("td").at(2).text(), "(no data)");
+ });
+ it("should dispatch DISCOVERY_STREAM_PERSONALIZATION_TOGGLE", () => {
+ wrapper.instance().togglePersonalization();
+ assert.calledWith(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE,
+ })
+ );
+ });
+ });
+
+ describe("#ToggleStoryButton", () => {
+ it("should fire onClick in toggle button", async () => {
+ let result = "";
+ function onClick(spoc) {
+ result = spoc;
+ }
+
+ wrapper = shallow(<ToggleStoryButton story="spoc" onClick={onClick} />);
+ wrapper.find("button").simulate("click");
+
+ assert.equal(result, "spoc");
+ });
+ });
+});
+
+describe("CollapseToggle", () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = shallow(<CollapseToggle location={{ routes: [""] }} />);
+ });
+
+ describe("rendering inner content", () => {
+ it("should not render ASRouterAdminInner for about:newtab (no hash)", () => {
+ wrapper.setProps({ location: { hash: "", routes: [""] } });
+ assert.lengthOf(wrapper.find(ASRouterAdminInner), 0);
+ });
+
+ it("should render ASRouterAdminInner for about:newtab#asrouter and subroutes", () => {
+ wrapper.setProps({ location: { hash: "#asrouter", routes: [""] } });
+ assert.lengthOf(wrapper.find(ASRouterAdminInner), 1);
+
+ wrapper.setProps({ location: { hash: "#asrouter-foo", routes: [""] } });
+ assert.lengthOf(wrapper.find(ASRouterAdminInner), 1);
+ });
+
+ it("should render ASRouterAdminInner for about:newtab#devtools and subroutes", () => {
+ wrapper.setProps({ location: { hash: "#devtools", routes: [""] } });
+ assert.lengthOf(wrapper.find(ASRouterAdminInner), 1);
+
+ wrapper.setProps({ location: { hash: "#devtools-foo", routes: [""] } });
+ assert.lengthOf(wrapper.find(ASRouterAdminInner), 1);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/Base.test.jsx b/browser/components/newtab/test/unit/content-src/components/Base.test.jsx
new file mode 100644
index 0000000000..3dd7a3d536
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/Base.test.jsx
@@ -0,0 +1,130 @@
+import {
+ _Base as Base,
+ BaseContent,
+ PrefsButton,
+} from "content-src/components/Base/Base";
+import { ASRouterAdmin } from "content-src/components/ASRouterAdmin/ASRouterAdmin";
+import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
+import React from "react";
+import { Search } from "content-src/components/Search/Search";
+import { shallow } from "enzyme";
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+
+describe("<Base>", () => {
+ let DEFAULT_PROPS = {
+ store: { getState: () => {} },
+ App: { initialized: true },
+ Prefs: { values: {} },
+ Sections: [],
+ DiscoveryStream: { config: { enabled: false } },
+ dispatch: () => {},
+ adminContent: {
+ message: {},
+ },
+ };
+
+ it("should render Base component", () => {
+ const wrapper = shallow(<Base {...DEFAULT_PROPS} />);
+ assert.ok(wrapper.exists());
+ });
+
+ it("should render the BaseContent component, passing through all props", () => {
+ const wrapper = shallow(<Base {...DEFAULT_PROPS} />);
+ const props = wrapper.find(BaseContent).props();
+ assert.deepEqual(
+ props,
+ DEFAULT_PROPS,
+ JSON.stringify([props, DEFAULT_PROPS], null, 3)
+ );
+ });
+
+ it("should render an ErrorBoundary with class base-content-fallback", () => {
+ const wrapper = shallow(<Base {...DEFAULT_PROPS} />);
+
+ assert.equal(
+ wrapper.find(ErrorBoundary).first().prop("className"),
+ "base-content-fallback"
+ );
+ });
+
+ it("should render an ASRouterAdmin if the devtools pref is true", () => {
+ const wrapper = shallow(
+ <Base
+ {...DEFAULT_PROPS}
+ Prefs={{ values: { "asrouter.devtoolsEnabled": true } }}
+ />
+ );
+ assert.lengthOf(wrapper.find(ASRouterAdmin), 1);
+ });
+
+ it("should not render an ASRouterAdmin if the devtools pref is false", () => {
+ const wrapper = shallow(
+ <Base
+ {...DEFAULT_PROPS}
+ Prefs={{ values: { "asrouter.devtoolsEnabled": false } }}
+ />
+ );
+ assert.lengthOf(wrapper.find(ASRouterAdmin), 0);
+ });
+});
+
+describe("<BaseContent>", () => {
+ let DEFAULT_PROPS = {
+ store: { getState: () => {} },
+ App: { initialized: true },
+ Prefs: { values: {} },
+ Sections: [],
+ DiscoveryStream: { config: { enabled: false } },
+ dispatch: () => {},
+ };
+
+ it("should render an ErrorBoundary with a Search child", () => {
+ const searchEnabledProps = Object.assign({}, DEFAULT_PROPS, {
+ Prefs: { values: { showSearch: true } },
+ });
+
+ const wrapper = shallow(<BaseContent {...searchEnabledProps} />);
+
+ assert.isTrue(wrapper.find(Search).parent().is(ErrorBoundary));
+ });
+
+ it("should dispatch a user event when the customize menu is opened or closed", () => {
+ const dispatch = sinon.stub();
+ const wrapper = shallow(
+ <BaseContent
+ {...DEFAULT_PROPS}
+ dispatch={dispatch}
+ App={{ customizeMenuVisible: true }}
+ />
+ );
+ wrapper.instance().openCustomizationMenu();
+ assert.calledWith(dispatch, { type: "SHOW_PERSONALIZE" });
+ assert.calledWith(dispatch, ac.UserEvent({ event: "SHOW_PERSONALIZE" }));
+ wrapper.instance().closeCustomizationMenu();
+ assert.calledWith(dispatch, { type: "HIDE_PERSONALIZE" });
+ assert.calledWith(dispatch, ac.UserEvent({ event: "HIDE_PERSONALIZE" }));
+ });
+
+ it("should render only search if no Sections are enabled", () => {
+ const onlySearchProps = Object.assign({}, DEFAULT_PROPS, {
+ Sections: [{ id: "highlights", enabled: false }],
+ Prefs: { values: { showSearch: true } },
+ });
+
+ const wrapper = shallow(<BaseContent {...onlySearchProps} />);
+ assert.lengthOf(wrapper.find(".only-search"), 1);
+ });
+});
+
+describe("<PrefsButton>", () => {
+ it("should render icon-settings if props.icon is empty", () => {
+ const wrapper = shallow(<PrefsButton icon="" />);
+
+ assert.isTrue(wrapper.find("button").hasClass("icon-settings"));
+ });
+ it("should render props.icon as a className", () => {
+ const wrapper = shallow(<PrefsButton icon="icon-happy" />);
+
+ assert.isTrue(wrapper.find("button").hasClass("icon-happy"));
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/Card.test.jsx b/browser/components/newtab/test/unit/content-src/components/Card.test.jsx
new file mode 100644
index 0000000000..5f07570b2e
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/Card.test.jsx
@@ -0,0 +1,510 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import {
+ _Card as Card,
+ PlaceholderCard,
+} from "content-src/components/Card/Card";
+import { combineReducers, createStore } from "redux";
+import { GlobalOverrider } from "test/unit/utils";
+import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
+import { cardContextTypes } from "content-src/components/Card/types";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import { Provider } from "react-redux";
+import React from "react";
+import { shallow, mount } from "enzyme";
+
+let DEFAULT_PROPS = {
+ dispatch: sinon.stub(),
+ index: 0,
+ link: {
+ hostname: "foo",
+ title: "A title for foo",
+ url: "http://www.foo.com",
+ type: "history",
+ description: "A description for foo",
+ image: "http://www.foo.com/img.png",
+ guid: 1,
+ },
+ eventSource: "TOP_STORIES",
+ shouldSendImpressionStats: true,
+ contextMenuOptions: ["Separator"],
+};
+
+let DEFAULT_BLOB_IMAGE = {
+ path: "/testpath",
+ data: new Blob([0]),
+};
+
+function mountCardWithProps(props) {
+ const store = createStore(combineReducers(reducers), INITIAL_STATE);
+ return mount(
+ <Provider store={store}>
+ <Card {...props} />
+ </Provider>
+ );
+}
+
+describe("<Card>", () => {
+ let globals;
+ let wrapper;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ wrapper = mountCardWithProps(DEFAULT_PROPS);
+ });
+ afterEach(() => {
+ DEFAULT_PROPS.dispatch.reset();
+ globals.restore();
+ });
+ it("should render a Card component", () => assert.ok(wrapper.exists()));
+ it("should add the right url", () => {
+ assert.propertyVal(
+ wrapper.find("a").props(),
+ "href",
+ DEFAULT_PROPS.link.url
+ );
+
+ // test that pocket cards get a special open_url href
+ const pocketLink = Object.assign({}, DEFAULT_PROPS.link, {
+ open_url: "getpocket.com/foo",
+ type: "pocket",
+ });
+ wrapper = mount(
+ <Card {...Object.assign({}, DEFAULT_PROPS, { link: pocketLink })} />
+ );
+ assert.propertyVal(wrapper.find("a").props(), "href", pocketLink.open_url);
+ });
+ it("should display a title", () =>
+ assert.equal(wrapper.find(".card-title").text(), DEFAULT_PROPS.link.title));
+ it("should display a description", () =>
+ assert.equal(
+ wrapper.find(".card-description").text(),
+ DEFAULT_PROPS.link.description
+ ));
+ it("should display a host name", () =>
+ assert.equal(wrapper.find(".card-host-name").text(), "foo"));
+ it("should have a link menu button", () =>
+ assert.ok(wrapper.find(".context-menu-button").exists()));
+ it("should render a link menu when button is clicked", () => {
+ const button = wrapper.find(".context-menu-button");
+ assert.equal(wrapper.find(LinkMenu).length, 0);
+ button.simulate("click", { preventDefault: () => {} });
+ assert.equal(wrapper.find(LinkMenu).length, 1);
+ });
+ it("should pass dispatch, source, onUpdate, site, options, and index to LinkMenu", () => {
+ wrapper
+ .find(".context-menu-button")
+ .simulate("click", { preventDefault: () => {} });
+ const { dispatch, source, onUpdate, site, options, index } = wrapper
+ .find(LinkMenu)
+ .props();
+ assert.equal(dispatch, DEFAULT_PROPS.dispatch);
+ assert.equal(source, DEFAULT_PROPS.eventSource);
+ assert.ok(onUpdate);
+ assert.equal(site, DEFAULT_PROPS.link);
+ assert.equal(options, DEFAULT_PROPS.contextMenuOptions);
+ assert.equal(index, DEFAULT_PROPS.index);
+ });
+ it("should pass through the correct menu options to LinkMenu if overridden by individual card", () => {
+ const link = Object.assign({}, DEFAULT_PROPS.link);
+ link.contextMenuOptions = ["CheckBookmark"];
+
+ wrapper = mountCardWithProps(Object.assign({}, DEFAULT_PROPS, { link }));
+ wrapper
+ .find(".context-menu-button")
+ .simulate("click", { preventDefault: () => {} });
+ const { options } = wrapper.find(LinkMenu).props();
+ assert.equal(options, link.contextMenuOptions);
+ });
+ it("should have a context based on type", () => {
+ wrapper = shallow(<Card {...DEFAULT_PROPS} />);
+ const context = wrapper.find(".card-context");
+ const { icon, fluentID } = cardContextTypes[DEFAULT_PROPS.link.type];
+ assert.isTrue(context.childAt(0).hasClass(`icon-${icon}`));
+ assert.isTrue(context.childAt(1).hasClass("card-context-label"));
+ assert.equal(context.childAt(1).prop("data-l10n-id"), fluentID);
+ });
+ it("should support setting custom context", () => {
+ const linkWithCustomContext = {
+ type: "history",
+ context: "Custom",
+ icon: "icon-url",
+ };
+
+ wrapper = shallow(
+ <Card
+ {...Object.assign({}, DEFAULT_PROPS, { link: linkWithCustomContext })}
+ />
+ );
+ const context = wrapper.find(".card-context");
+ const { icon } = cardContextTypes[DEFAULT_PROPS.link.type];
+ assert.isFalse(context.childAt(0).hasClass(`icon-${icon}`));
+ assert.equal(
+ context.childAt(0).props().style.backgroundImage,
+ "url('icon-url')"
+ );
+
+ assert.isTrue(context.childAt(1).hasClass("card-context-label"));
+ assert.equal(context.childAt(1).text(), linkWithCustomContext.context);
+ });
+ it("should parse args for fluent correctly", () => {
+ const title = '"fluent"';
+ const link = { ...DEFAULT_PROPS.link, title };
+
+ wrapper = mountCardWithProps({ ...DEFAULT_PROPS, link });
+ let button = wrapper.find(ContextMenuButton).find("button");
+
+ assert.equal(button.prop("data-l10n-args"), JSON.stringify({ title }));
+ });
+ it("should have .active class, on card-outer if context menu is open", () => {
+ const button = wrapper.find(ContextMenuButton);
+ assert.isFalse(
+ wrapper.find(".card-outer").hasClass("active"),
+ "does not have active class"
+ );
+ button.simulate("click", { preventDefault: () => {} });
+ assert.isTrue(
+ wrapper.find(".card-outer").hasClass("active"),
+ "has active class"
+ );
+ });
+ it("should send OPEN_DOWNLOAD_FILE if we clicked on a download", () => {
+ const downloadLink = {
+ type: "download",
+ url: "download.mov",
+ };
+ wrapper = mountCardWithProps(
+ Object.assign({}, DEFAULT_PROPS, { link: downloadLink })
+ );
+ const card = wrapper.find(".card");
+ card.simulate("click", { preventDefault: () => {} });
+ assert.calledThrice(DEFAULT_PROPS.dispatch);
+
+ assert.equal(
+ DEFAULT_PROPS.dispatch.firstCall.args[0].type,
+ at.OPEN_DOWNLOAD_FILE
+ );
+ assert.deepEqual(
+ DEFAULT_PROPS.dispatch.firstCall.args[0].data,
+ downloadLink
+ );
+ });
+ it("should send OPEN_LINK if we clicked on anything other than a download", () => {
+ const nonDownloadLink = {
+ type: "history",
+ url: "download.mov",
+ };
+ wrapper = mountCardWithProps(
+ Object.assign({}, DEFAULT_PROPS, { link: nonDownloadLink })
+ );
+ const card = wrapper.find(".card");
+ const event = {
+ altKey: "1",
+ button: "2",
+ ctrlKey: "3",
+ metaKey: "4",
+ shiftKey: "5",
+ };
+ card.simulate(
+ "click",
+ Object.assign({}, event, { preventDefault: () => {} })
+ );
+ assert.calledThrice(DEFAULT_PROPS.dispatch);
+
+ assert.equal(DEFAULT_PROPS.dispatch.firstCall.args[0].type, at.OPEN_LINK);
+ });
+ describe("card image display", () => {
+ const DEFAULT_BLOB_URL = "blob://test";
+ let url;
+ beforeEach(() => {
+ url = {
+ createObjectURL: globals.sandbox.stub().returns(DEFAULT_BLOB_URL),
+ revokeObjectURL: globals.sandbox.spy(),
+ };
+ globals.set("URL", url);
+ });
+ afterEach(() => {
+ globals.restore();
+ });
+ it("should display a regular image correctly and not call revokeObjectURL when unmounted", () => {
+ wrapper = shallow(<Card {...DEFAULT_PROPS} />);
+
+ assert.isUndefined(wrapper.state("cardImage").path);
+ assert.equal(wrapper.state("cardImage").url, DEFAULT_PROPS.link.image);
+ assert.equal(
+ wrapper.find(".card-preview-image").props().style.backgroundImage,
+ `url(${wrapper.state("cardImage").url})`
+ );
+
+ wrapper.unmount();
+ assert.notCalled(url.revokeObjectURL);
+ });
+ it("should display a blob image correctly and revoke blob url when unmounted", () => {
+ const link = Object.assign({}, DEFAULT_PROPS.link, {
+ image: DEFAULT_BLOB_IMAGE,
+ });
+ wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
+
+ assert.equal(wrapper.state("cardImage").path, DEFAULT_BLOB_IMAGE.path);
+ assert.equal(wrapper.state("cardImage").url, DEFAULT_BLOB_URL);
+ assert.equal(
+ wrapper.find(".card-preview-image").props().style.backgroundImage,
+ `url(${wrapper.state("cardImage").url})`
+ );
+
+ wrapper.unmount();
+ assert.calledOnce(url.revokeObjectURL);
+ });
+ it("should not show an image if there isn't one and not call revokeObjectURL when unmounted", () => {
+ const link = Object.assign({}, DEFAULT_PROPS.link);
+ delete link.image;
+
+ wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
+
+ assert.isNull(wrapper.state("cardImage"));
+ assert.lengthOf(wrapper.find(".card-preview-image"), 0);
+
+ wrapper.unmount();
+ assert.notCalled(url.revokeObjectURL);
+ });
+ it("should remove current card image if new image is not present", () => {
+ wrapper = shallow(<Card {...DEFAULT_PROPS} />);
+
+ const otherLink = Object.assign({}, DEFAULT_PROPS.link);
+ delete otherLink.image;
+ wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
+
+ assert.isNull(wrapper.state("cardImage"));
+ });
+ it("should not create or revoke urls if normal image is already in state", () => {
+ wrapper = shallow(<Card {...DEFAULT_PROPS} />);
+
+ wrapper.setProps(DEFAULT_PROPS);
+
+ assert.notCalled(url.createObjectURL);
+ assert.notCalled(url.revokeObjectURL);
+ });
+ it("should not create or revoke more urls if blob image is already in state", () => {
+ const link = Object.assign({}, DEFAULT_PROPS.link, {
+ image: DEFAULT_BLOB_IMAGE,
+ });
+ wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
+
+ assert.calledOnce(url.createObjectURL);
+ assert.notCalled(url.revokeObjectURL);
+
+ wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link }));
+
+ assert.calledOnce(url.createObjectURL);
+ assert.notCalled(url.revokeObjectURL);
+ });
+ it("should create blob urls for new blobs and revoke existing ones", () => {
+ const link = Object.assign({}, DEFAULT_PROPS.link, {
+ image: DEFAULT_BLOB_IMAGE,
+ });
+ wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
+
+ assert.calledOnce(url.createObjectURL);
+ assert.notCalled(url.revokeObjectURL);
+
+ const otherLink = Object.assign({}, DEFAULT_PROPS.link, {
+ image: { path: "/newpath", data: new Blob([0]) },
+ });
+ wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
+
+ assert.calledTwice(url.createObjectURL);
+ assert.calledOnce(url.revokeObjectURL);
+ });
+ it("should not call createObjectURL and revokeObjectURL for normal images", () => {
+ wrapper = shallow(<Card {...DEFAULT_PROPS} />);
+
+ assert.notCalled(url.createObjectURL);
+ assert.notCalled(url.revokeObjectURL);
+
+ const otherLink = Object.assign({}, DEFAULT_PROPS.link, {
+ image: "https://other/image",
+ });
+ wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
+
+ assert.notCalled(url.createObjectURL);
+ assert.notCalled(url.revokeObjectURL);
+ });
+ });
+ describe("image loading", () => {
+ let link;
+ let triggerImage = {};
+ let uniqueLink = 0;
+ beforeEach(() => {
+ global.Image.prototype = {
+ addEventListener(event, callback) {
+ triggerImage[event] = () => Promise.resolve(callback());
+ },
+ };
+
+ link = Object.assign({}, DEFAULT_PROPS.link);
+ link.image += uniqueLink++;
+ wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
+ });
+ it("should have a loaded preview image when the image is loaded", () => {
+ assert.isFalse(wrapper.find(".card-preview-image").hasClass("loaded"));
+
+ wrapper.setState({ imageLoaded: true });
+
+ assert.isTrue(wrapper.find(".card-preview-image").hasClass("loaded"));
+ });
+ it("should start not loaded", () => {
+ assert.isFalse(wrapper.state("imageLoaded"));
+ });
+ it("should be loaded after load", async () => {
+ await triggerImage.load();
+
+ assert.isTrue(wrapper.state("imageLoaded"));
+ });
+ it("should be not be loaded after error ", async () => {
+ await triggerImage.error();
+
+ assert.isFalse(wrapper.state("imageLoaded"));
+ });
+ it("should be not be loaded if image changes", async () => {
+ await triggerImage.load();
+ const otherLink = Object.assign({}, link, {
+ image: "https://other/image",
+ });
+
+ wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
+
+ assert.isFalse(wrapper.state("imageLoaded"));
+ });
+ });
+ describe("placeholder=true", () => {
+ beforeEach(() => {
+ wrapper = mount(<Card placeholder={true} />);
+ });
+ it("should render when placeholder=true", () => {
+ assert.ok(wrapper.exists());
+ });
+ it("should add a placeholder class to the outer element", () => {
+ assert.isTrue(wrapper.find(".card-outer").hasClass("placeholder"));
+ });
+ it("should not have a context menu button or LinkMenu", () => {
+ assert.isFalse(
+ wrapper.find(ContextMenuButton).exists(),
+ "context menu button"
+ );
+ assert.isFalse(wrapper.find(LinkMenu).exists(), "LinkMenu");
+ });
+ it("should not call onLinkClick when the link is clicked", () => {
+ const spy = sinon.spy(wrapper.instance(), "onLinkClick");
+ const card = wrapper.find(".card");
+ card.simulate("click");
+ assert.notCalled(spy);
+ });
+ });
+ describe("#trackClick", () => {
+ it("should call dispatch when the link is clicked with the right data", () => {
+ const card = wrapper.find(".card");
+ const event = {
+ altKey: "1",
+ button: "2",
+ ctrlKey: "3",
+ metaKey: "4",
+ shiftKey: "5",
+ };
+ card.simulate(
+ "click",
+ Object.assign({}, event, { preventDefault: () => {} })
+ );
+ assert.calledThrice(DEFAULT_PROPS.dispatch);
+
+ // first dispatch call is the AlsoToMain message which will open a link in a window, and send some event data
+ assert.equal(DEFAULT_PROPS.dispatch.firstCall.args[0].type, at.OPEN_LINK);
+ assert.deepEqual(
+ DEFAULT_PROPS.dispatch.firstCall.args[0].data.event,
+ event
+ );
+
+ // second dispatch call is a UserEvent action for telemetry
+ assert.isUserEventAction(DEFAULT_PROPS.dispatch.secondCall.args[0]);
+ assert.calledWith(
+ DEFAULT_PROPS.dispatch.secondCall,
+ ac.UserEvent({
+ event: "CLICK",
+ source: DEFAULT_PROPS.eventSource,
+ action_position: DEFAULT_PROPS.index,
+ })
+ );
+
+ // third dispatch call is to send impression stats
+ assert.calledWith(
+ DEFAULT_PROPS.dispatch.thirdCall,
+ ac.ImpressionStats({
+ source: DEFAULT_PROPS.eventSource,
+ click: 0,
+ tiles: [{ id: DEFAULT_PROPS.link.guid, pos: DEFAULT_PROPS.index }],
+ })
+ );
+ });
+ it("should provide card_type to telemetry info if type is not history", () => {
+ const link = Object.assign({}, DEFAULT_PROPS.link);
+ link.type = "bookmark";
+ wrapper = mount(<Card {...Object.assign({}, DEFAULT_PROPS, { link })} />);
+ const card = wrapper.find(".card");
+ const event = {
+ altKey: "1",
+ button: "2",
+ ctrlKey: "3",
+ metaKey: "4",
+ shiftKey: "5",
+ };
+
+ card.simulate(
+ "click",
+ Object.assign({}, event, { preventDefault: () => {} })
+ );
+
+ assert.isUserEventAction(DEFAULT_PROPS.dispatch.secondCall.args[0]);
+ assert.calledWith(
+ DEFAULT_PROPS.dispatch.secondCall,
+ ac.UserEvent({
+ event: "CLICK",
+ source: DEFAULT_PROPS.eventSource,
+ action_position: DEFAULT_PROPS.index,
+ value: { card_type: link.type },
+ })
+ );
+ });
+ it("should notify Web Extensions with WEBEXT_CLICK if props.isWebExtension is true", () => {
+ wrapper = mountCardWithProps(
+ Object.assign({}, DEFAULT_PROPS, {
+ isWebExtension: true,
+ eventSource: "MyExtension",
+ index: 3,
+ })
+ );
+ const card = wrapper.find(".card");
+ const event = { preventDefault() {} };
+ card.simulate("click", event);
+ assert.calledWith(
+ DEFAULT_PROPS.dispatch,
+ ac.WebExtEvent(at.WEBEXT_CLICK, {
+ source: "MyExtension",
+ url: DEFAULT_PROPS.link.url,
+ action_position: 3,
+ })
+ );
+ });
+ });
+});
+
+describe("<PlaceholderCard />", () => {
+ it("should render a Card with placeholder=true", () => {
+ const wrapper = mount(
+ <Provider store={createStore(combineReducers(reducers), INITIAL_STATE)}>
+ <PlaceholderCard />
+ </Provider>
+ );
+ assert.isTrue(wrapper.find(Card).props().placeholder);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/CollapsibleSection.test.jsx b/browser/components/newtab/test/unit/content-src/components/CollapsibleSection.test.jsx
new file mode 100644
index 0000000000..f2a8e276b4
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/CollapsibleSection.test.jsx
@@ -0,0 +1,67 @@
+import { _CollapsibleSection as CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
+import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
+import { mount } from "enzyme";
+import React from "react";
+
+const DEFAULT_PROPS = {
+ id: "cool",
+ className: "cool-section",
+ title: "Cool Section",
+ prefName: "collapseSection",
+ collapsed: false,
+ eventSource: "foo",
+ document: {
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ visibilityState: "visible",
+ },
+ dispatch: () => {},
+ Prefs: { values: { featureConfig: {} } },
+};
+
+describe("CollapsibleSection", () => {
+ let wrapper;
+
+ function setup(props = {}) {
+ const customProps = Object.assign({}, DEFAULT_PROPS, props);
+ wrapper = mount(
+ <CollapsibleSection {...customProps}>foo</CollapsibleSection>
+ );
+ }
+
+ beforeEach(() => setup());
+
+ it("should render the component", () => {
+ assert.ok(wrapper.exists());
+ });
+
+ it("should render an ErrorBoundary with class section-body-fallback", () => {
+ assert.equal(
+ wrapper.find(ErrorBoundary).first().prop("className"),
+ "section-body-fallback"
+ );
+ });
+
+ describe("without collapsible pref", () => {
+ let dispatch;
+ beforeEach(() => {
+ dispatch = sinon.stub();
+ setup({ collapsed: undefined, dispatch });
+ });
+ it("should render the section uncollapsed", () => {
+ assert.isFalse(
+ wrapper.find(".collapsible-section").first().hasClass("collapsed")
+ );
+ });
+
+ it("should not render the arrow if no collapsible pref exists for the section", () => {
+ assert.lengthOf(wrapper.find(".click-target .collapsible-arrow"), 0);
+ });
+ });
+
+ describe("icon", () => {
+ it("no icon should be shown", () => {
+ assert.lengthOf(wrapper.find(".icon"), 0);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx b/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx
new file mode 100644
index 0000000000..baf203947e
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx
@@ -0,0 +1,447 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
+import createMockRaf from "mock-raf";
+import React from "react";
+
+import { shallow } from "enzyme";
+
+const perfSvc = {
+ mark() {},
+ getMostRecentAbsMarkStartByName() {},
+};
+
+let DEFAULT_PROPS = {
+ initialized: true,
+ rows: [],
+ id: "highlights",
+ dispatch() {},
+ perfSvc,
+};
+
+describe("<ComponentPerfTimer>", () => {
+ let mockRaf;
+ let sandbox;
+ let wrapper;
+
+ const InnerEl = () => <div>Inner Element</div>;
+
+ beforeEach(() => {
+ mockRaf = createMockRaf();
+ sandbox = sinon.createSandbox();
+ sandbox.stub(window, "requestAnimationFrame").callsFake(mockRaf.raf);
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render props.children", () => {
+ assert.ok(wrapper.contains(<InnerEl />));
+ });
+
+ describe("#constructor", () => {
+ beforeEach(() => {
+ sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent");
+ sandbox.stub(
+ ComponentPerfTimer.prototype,
+ "_ensureFirstRenderTsRecorded"
+ );
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>,
+ { disableLifecycleMethods: true }
+ );
+ });
+
+ it("should have the correct defaults", () => {
+ const instance = wrapper.instance();
+
+ assert.isFalse(instance._reportMissingData);
+ assert.isFalse(instance._timestampHandled);
+ assert.isFalse(instance._recordedFirstRender);
+ });
+ });
+
+ describe("#render", () => {
+ beforeEach(() => {
+ sandbox.stub(DEFAULT_PROPS, "id").value("fake_section");
+ sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent");
+ sandbox.stub(
+ ComponentPerfTimer.prototype,
+ "_ensureFirstRenderTsRecorded"
+ );
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+ });
+
+ it("should not call telemetry on sections that we don't want to record", () => {
+ const instance = wrapper.instance();
+
+ assert.notCalled(instance._maybeSendBadStateEvent);
+ assert.notCalled(instance._ensureFirstRenderTsRecorded);
+ });
+ });
+
+ describe("#_componentDidMount", () => {
+ it("should call _maybeSendPaintedEvent", () => {
+ const instance = wrapper.instance();
+ const stub = sandbox.stub(instance, "_maybeSendPaintedEvent");
+
+ instance.componentDidMount();
+
+ assert.calledOnce(stub);
+ });
+
+ it("should not call _maybeSendPaintedEvent if id not in RECORDED_SECTIONS", () => {
+ sandbox.stub(DEFAULT_PROPS, "id").value("topstories");
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+ const instance = wrapper.instance();
+ const stub = sandbox.stub(instance, "_maybeSendPaintedEvent");
+
+ instance.componentDidMount();
+
+ assert.notCalled(stub);
+ });
+ });
+
+ describe("#_componentDidUpdate", () => {
+ it("should call _maybeSendPaintedEvent", () => {
+ const instance = wrapper.instance();
+ const maybeSendPaintStub = sandbox.stub(
+ instance,
+ "_maybeSendPaintedEvent"
+ );
+
+ instance.componentDidUpdate();
+
+ assert.calledOnce(maybeSendPaintStub);
+ });
+
+ it("should not call _maybeSendPaintedEvent if id not in RECORDED_SECTIONS", () => {
+ sandbox.stub(DEFAULT_PROPS, "id").value("topstories");
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+ const instance = wrapper.instance();
+ const stub = sandbox.stub(instance, "_maybeSendPaintedEvent");
+
+ instance.componentDidUpdate();
+
+ assert.notCalled(stub);
+ });
+ });
+
+ describe("_ensureFirstRenderTsRecorded", () => {
+ let recordFirstRenderStub;
+ beforeEach(() => {
+ sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent");
+ recordFirstRenderStub = sandbox.stub(
+ ComponentPerfTimer.prototype,
+ "_ensureFirstRenderTsRecorded"
+ );
+ });
+
+ it("should set _recordedFirstRender", () => {
+ sandbox.stub(DEFAULT_PROPS, "initialized").value(false);
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+ const instance = wrapper.instance();
+
+ assert.isFalse(instance._recordedFirstRender);
+
+ recordFirstRenderStub.callThrough();
+ instance._ensureFirstRenderTsRecorded();
+
+ assert.isTrue(instance._recordedFirstRender);
+ });
+
+ it("should mark first_render_ts", () => {
+ sandbox.stub(DEFAULT_PROPS, "initialized").value(false);
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+ const instance = wrapper.instance();
+ const stub = sandbox.stub(perfSvc, "mark");
+
+ recordFirstRenderStub.callThrough();
+ instance._ensureFirstRenderTsRecorded();
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, `${DEFAULT_PROPS.id}_first_render_ts`);
+ });
+ });
+
+ describe("#_maybeSendBadStateEvent", () => {
+ let sendBadStateStub;
+ beforeEach(() => {
+ sendBadStateStub = sandbox.stub(
+ ComponentPerfTimer.prototype,
+ "_maybeSendBadStateEvent"
+ );
+ sandbox.stub(
+ ComponentPerfTimer.prototype,
+ "_ensureFirstRenderTsRecorded"
+ );
+ });
+
+ it("should set this._reportMissingData=true when called with initialized === false", () => {
+ sandbox.stub(DEFAULT_PROPS, "initialized").value(false);
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+ const instance = wrapper.instance();
+
+ assert.isFalse(instance._reportMissingData);
+
+ sendBadStateStub.callThrough();
+ instance._maybeSendBadStateEvent();
+
+ assert.isTrue(instance._reportMissingData);
+ });
+
+ it("should call _sendBadStateEvent if initialized & other metrics have been recorded", () => {
+ const instance = wrapper.instance();
+ const stub = sandbox.stub(instance, "_sendBadStateEvent");
+ instance._reportMissingData = true;
+ instance._timestampHandled = true;
+ instance._recordedFirstRender = true;
+
+ sendBadStateStub.callThrough();
+ instance._maybeSendBadStateEvent();
+
+ assert.calledOnce(stub);
+ assert.isFalse(instance._reportMissingData);
+ });
+ });
+
+ describe("#_maybeSendPaintedEvent", () => {
+ it("should call _sendPaintedEvent if props.initialized is true", () => {
+ sandbox.stub(DEFAULT_PROPS, "initialized").value(true);
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>,
+ { disableLifecycleMethods: true }
+ );
+ const instance = wrapper.instance();
+ const stub = sandbox.stub(instance, "_afterFramePaint");
+
+ assert.isFalse(instance._timestampHandled);
+
+ instance._maybeSendPaintedEvent();
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, instance._sendPaintedEvent);
+ assert.isTrue(wrapper.instance()._timestampHandled);
+ });
+ it("should not call _sendPaintedEvent if this._timestampHandled is true", () => {
+ const instance = wrapper.instance();
+ const spy = sinon.spy(instance, "_afterFramePaint");
+ instance._timestampHandled = true;
+
+ instance._maybeSendPaintedEvent();
+ spy.neverCalledWith(instance._sendPaintedEvent);
+ });
+ it("should not call _sendPaintedEvent if component not initialized", () => {
+ sandbox.stub(DEFAULT_PROPS, "initialized").value(false);
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+ const instance = wrapper.instance();
+ const spy = sinon.spy(instance, "_afterFramePaint");
+
+ instance._maybeSendPaintedEvent();
+
+ spy.neverCalledWith(instance._sendPaintedEvent);
+ });
+ });
+
+ describe("#_afterFramePaint", () => {
+ it("should call callback after the requestAnimationFrame callback returns", () =>
+ new Promise(resolve => {
+ // Setting the callback to resolve is the test that it does finally get
+ // called at the correct time, after the event loop ticks again.
+ // If it doesn't get called, this test will time out.
+ const callback = sandbox.spy(resolve);
+
+ const instance = wrapper.instance();
+
+ instance._afterFramePaint(callback);
+
+ assert.notCalled(callback);
+ mockRaf.step({ count: 1 });
+ }));
+ });
+
+ describe("#_sendBadStateEvent", () => {
+ it("should call perfSvc.mark", () => {
+ sandbox.spy(perfSvc, "mark");
+ const key = `${DEFAULT_PROPS.id}_data_ready_ts`;
+
+ wrapper.instance()._sendBadStateEvent();
+
+ assert.calledOnce(perfSvc.mark);
+ assert.calledWithExactly(perfSvc.mark, key);
+ });
+
+ it("should call compute the delta from first render to data ready", () => {
+ sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName");
+
+ wrapper
+ .instance()
+ ._sendBadStateEvent(`${DEFAULT_PROPS.id}_data_ready_ts`);
+
+ assert.calledTwice(perfSvc.getMostRecentAbsMarkStartByName);
+ assert.calledWithExactly(
+ perfSvc.getMostRecentAbsMarkStartByName,
+ `${DEFAULT_PROPS.id}_data_ready_ts`
+ );
+ assert.calledWithExactly(
+ perfSvc.getMostRecentAbsMarkStartByName,
+ `${DEFAULT_PROPS.id}_first_render_ts`
+ );
+ });
+
+ it("should call dispatch SAVE_SESSION_PERF_DATA", () => {
+ sandbox
+ .stub(perfSvc, "getMostRecentAbsMarkStartByName")
+ .withArgs("highlights_first_render_ts")
+ .returns(0.5)
+ .withArgs("highlights_data_ready_ts")
+ .returns(3.2);
+
+ const dispatch = sandbox.spy(DEFAULT_PROPS, "dispatch");
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+
+ wrapper.instance()._sendBadStateEvent();
+
+ assert.calledOnce(dispatch);
+ assert.calledWithExactly(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: { [`${DEFAULT_PROPS.id}_data_late_by_ms`]: 2 },
+ })
+ );
+ });
+ });
+
+ describe("#_sendPaintedEvent", () => {
+ beforeEach(() => {
+ sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent");
+ sandbox.stub(
+ ComponentPerfTimer.prototype,
+ "_ensureFirstRenderTsRecorded"
+ );
+ });
+
+ it("should not call mark with the wrong id", () => {
+ sandbox.stub(perfSvc, "mark");
+ sandbox.stub(DEFAULT_PROPS, "id").value("fake_id");
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+
+ wrapper.instance()._sendPaintedEvent();
+
+ assert.notCalled(perfSvc.mark);
+ });
+ it("should call mark with the correct topsites", () => {
+ sandbox.stub(perfSvc, "mark");
+ sandbox.stub(DEFAULT_PROPS, "id").value("topsites");
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+
+ wrapper.instance()._sendPaintedEvent();
+
+ assert.calledOnce(perfSvc.mark);
+ assert.calledWithExactly(perfSvc.mark, "topsites_first_painted_ts");
+ });
+ it("should not call getMostRecentAbsMarkStartByName if id!=topsites", () => {
+ sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName");
+ sandbox.stub(DEFAULT_PROPS, "id").value("fake_id");
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+
+ wrapper.instance()._sendPaintedEvent();
+
+ assert.notCalled(perfSvc.getMostRecentAbsMarkStartByName);
+ });
+ it("should call getMostRecentAbsMarkStartByName for topsites", () => {
+ sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName");
+ sandbox.stub(DEFAULT_PROPS, "id").value("topsites");
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+
+ wrapper.instance()._sendPaintedEvent();
+
+ assert.calledOnce(perfSvc.getMostRecentAbsMarkStartByName);
+ assert.calledWithExactly(
+ perfSvc.getMostRecentAbsMarkStartByName,
+ "topsites_first_painted_ts"
+ );
+ });
+ it("should dispatch SAVE_SESSION_PERF_DATA", () => {
+ sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName").returns(42);
+ sandbox.stub(DEFAULT_PROPS, "id").value("topsites");
+ const dispatch = sandbox.spy(DEFAULT_PROPS, "dispatch");
+ wrapper = shallow(
+ <ComponentPerfTimer {...DEFAULT_PROPS}>
+ <InnerEl />
+ </ComponentPerfTimer>
+ );
+
+ wrapper.instance()._sendPaintedEvent();
+
+ assert.calledOnce(dispatch);
+ assert.calledWithExactly(
+ dispatch,
+ ac.OnlyToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: { topsites_first_painted_ts: 42 },
+ })
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx b/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx
new file mode 100644
index 0000000000..a471c09e66
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx
@@ -0,0 +1,182 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { _ConfirmDialog as ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<ConfirmDialog>", () => {
+ let wrapper;
+ let dispatch;
+ let ConfirmDialogProps;
+ beforeEach(() => {
+ dispatch = sinon.stub();
+ ConfirmDialogProps = {
+ visible: true,
+ data: {
+ onConfirm: [],
+ cancel_button_string_id: "newtab-topsites-delete-history-button",
+ confirm_button_string_id: "newtab-topsites-cancel-button",
+ eventSource: "HIGHLIGHTS",
+ },
+ };
+ wrapper = shallow(
+ <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} />
+ );
+ });
+ it("should render an overlay", () => {
+ assert.ok(wrapper.find(".modal-overlay").exists());
+ });
+ it("should render a modal", () => {
+ assert.ok(wrapper.find(".confirmation-dialog").exists());
+ });
+ it("should not render if visible is false", () => {
+ ConfirmDialogProps.visible = false;
+ wrapper = shallow(
+ <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} />
+ );
+
+ assert.lengthOf(wrapper.find(".confirmation-dialog"), 0);
+ });
+ it("should display an icon if we provide one in props", () => {
+ const iconName = "modal-icon";
+ // If there is no icon in the props, we shouldn't display an icon
+ assert.lengthOf(wrapper.find(`.icon-${iconName}`), 0);
+
+ ConfirmDialogProps.data.icon = iconName;
+ wrapper = shallow(
+ <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} />
+ );
+
+ // But if we do provide an icon - we should show it
+ assert.lengthOf(wrapper.find(`.icon-${iconName}`), 1);
+ });
+ describe("fluent message check", () => {
+ it("should render the message body sent via props", () => {
+ Object.assign(ConfirmDialogProps.data, {
+ body_string_id: ["foo", "bar"],
+ });
+ wrapper = shallow(
+ <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} />
+ );
+ let msgs = wrapper.find(".modal-message").find("p");
+ assert.equal(msgs.length, ConfirmDialogProps.data.body_string_id.length);
+ msgs.forEach((fm, i) =>
+ assert.equal(
+ fm.prop("data-l10n-id"),
+ ConfirmDialogProps.data.body_string_id[i]
+ )
+ );
+ });
+ it("should render the correct primary button text", () => {
+ Object.assign(ConfirmDialogProps.data, {
+ confirm_button_string_id: "primary_foo",
+ });
+ wrapper = shallow(
+ <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} />
+ );
+
+ let doneLabel = wrapper.find(".actions").childAt(1);
+ assert.ok(doneLabel.exists());
+ assert.equal(
+ doneLabel.prop("data-l10n-id"),
+ ConfirmDialogProps.data.confirm_button_string_id
+ );
+ });
+ });
+ describe("click events", () => {
+ it("should emit AlsoToMain DIALOG_CANCEL when you click the overlay", () => {
+ let overlay = wrapper.find(".modal-overlay");
+
+ assert.ok(overlay.exists());
+ overlay.simulate("click");
+
+ // Two events are emitted: UserEvent+AlsoToMain.
+ assert.calledTwice(dispatch);
+ assert.propertyVal(dispatch.firstCall.args[0], "type", at.DIALOG_CANCEL);
+ assert.calledWith(dispatch, { type: at.DIALOG_CANCEL });
+ });
+ it("should emit UserEvent DIALOG_CANCEL when you click the overlay", () => {
+ let overlay = wrapper.find(".modal-overlay");
+
+ assert.ok(overlay);
+ overlay.simulate("click");
+
+ // Two events are emitted: UserEvent+AlsoToMain.
+ assert.calledTwice(dispatch);
+ assert.isUserEventAction(dispatch.secondCall.args[0]);
+ assert.calledWith(
+ dispatch,
+ ac.UserEvent({ event: at.DIALOG_CANCEL, source: "HIGHLIGHTS" })
+ );
+ });
+ it("should emit AlsoToMain DIALOG_CANCEL on cancel", () => {
+ let cancelButton = wrapper.find(".actions").childAt(0);
+
+ assert.ok(cancelButton);
+ cancelButton.simulate("click");
+
+ // Two events are emitted: UserEvent+AlsoToMain.
+ assert.calledTwice(dispatch);
+ assert.propertyVal(dispatch.firstCall.args[0], "type", at.DIALOG_CANCEL);
+ assert.calledWith(dispatch, { type: at.DIALOG_CANCEL });
+ });
+ it("should emit UserEvent DIALOG_CANCEL on cancel", () => {
+ let cancelButton = wrapper.find(".actions").childAt(0);
+
+ assert.ok(cancelButton);
+ cancelButton.simulate("click");
+
+ // Two events are emitted: UserEvent+AlsoToMain.
+ assert.calledTwice(dispatch);
+ assert.isUserEventAction(dispatch.secondCall.args[0]);
+ assert.calledWith(
+ dispatch,
+ ac.UserEvent({ event: at.DIALOG_CANCEL, source: "HIGHLIGHTS" })
+ );
+ });
+ it("should emit UserEvent on primary button", () => {
+ Object.assign(ConfirmDialogProps.data, {
+ body_string_id: ["foo", "bar"],
+ onConfirm: [
+ ac.AlsoToMain({ type: at.DELETE_URL, data: "foo.bar" }),
+ ac.UserEvent({ event: "DELETE" }),
+ ],
+ });
+ wrapper = shallow(
+ <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} />
+ );
+ let doneButton = wrapper.find(".actions").childAt(1);
+
+ assert.ok(doneButton);
+ doneButton.simulate("click");
+
+ // Two events are emitted: UserEvent+AlsoToMain.
+ assert.isUserEventAction(dispatch.secondCall.args[0]);
+
+ assert.calledTwice(dispatch);
+ assert.calledWith(dispatch, ConfirmDialogProps.data.onConfirm[1]);
+ });
+ it("should emit AlsoToMain on primary button", () => {
+ Object.assign(ConfirmDialogProps.data, {
+ body_string_id: ["foo", "bar"],
+ onConfirm: [
+ ac.AlsoToMain({ type: at.DELETE_URL, data: "foo.bar" }),
+ ac.UserEvent({ event: "DELETE" }),
+ ],
+ });
+ wrapper = shallow(
+ <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} />
+ );
+ let doneButton = wrapper.find(".actions").childAt(1);
+
+ assert.ok(doneButton);
+ doneButton.simulate("click");
+
+ // Two events are emitted: UserEvent+AlsoToMain.
+ assert.calledTwice(dispatch);
+ assert.calledWith(dispatch, ConfirmDialogProps.data.onConfirm[0]);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/ContextMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/ContextMenu.test.jsx
new file mode 100644
index 0000000000..4f7edadc41
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/ContextMenu.test.jsx
@@ -0,0 +1,227 @@
+import {
+ ContextMenu,
+ ContextMenuItem,
+ _ContextMenuItem,
+} from "content-src/components/ContextMenu/ContextMenu";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+import { mount, shallow } from "enzyme";
+import React from "react";
+import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
+import { Provider } from "react-redux";
+import { combineReducers, createStore } from "redux";
+
+const DEFAULT_PROPS = {
+ onUpdate: () => {},
+ options: [],
+ tabbableOptionsLength: 0,
+};
+
+const DEFAULT_MENU_OPTIONS = [
+ "MoveUp",
+ "MoveDown",
+ "Separator",
+ "ManageSection",
+];
+
+const FakeMenu = props => {
+ return <div>{props.children}</div>;
+};
+
+describe("<ContextMenuButton>", () => {
+ function mountWithProps(options) {
+ const store = createStore(combineReducers(reducers), INITIAL_STATE);
+ return mount(
+ <Provider store={store}>
+ <ContextMenuButton>
+ <ContextMenu options={options} />
+ </ContextMenuButton>
+ </Provider>
+ );
+ }
+
+ let sandbox;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should call onUpdate when clicked", () => {
+ const onUpdate = sandbox.spy();
+ const wrapper = mount(
+ <ContextMenuButton onUpdate={onUpdate}>
+ <FakeMenu />
+ </ContextMenuButton>
+ );
+ wrapper.find(".context-menu-button").simulate("click");
+ assert.calledOnce(onUpdate);
+ });
+ it("should call onUpdate when activated with Enter", () => {
+ const onUpdate = sandbox.spy();
+ const wrapper = mount(
+ <ContextMenuButton onUpdate={onUpdate}>
+ <FakeMenu />
+ </ContextMenuButton>
+ );
+ wrapper.find(".context-menu-button").simulate("keydown", { key: "Enter" });
+ assert.calledOnce(onUpdate);
+ });
+ it("should call onClick", () => {
+ const onClick = sandbox.spy(ContextMenuButton.prototype, "onClick");
+ const wrapper = mount(
+ <ContextMenuButton>
+ <FakeMenu />
+ </ContextMenuButton>
+ );
+ wrapper.find("button").simulate("click");
+ assert.calledOnce(onClick);
+ });
+ it("should have a default keyboardAccess prop of false", () => {
+ const wrapper = mountWithProps(DEFAULT_MENU_OPTIONS);
+ wrapper.find(ContextMenuButton).setState({ showContextMenu: true });
+ assert.equal(wrapper.find(ContextMenu).prop("keyboardAccess"), false);
+ });
+ it("should pass the keyboardAccess prop down to ContextMenu", () => {
+ const wrapper = mountWithProps(DEFAULT_MENU_OPTIONS);
+ wrapper
+ .find(ContextMenuButton)
+ .setState({ showContextMenu: true, contextMenuKeyboard: true });
+ assert.equal(wrapper.find(ContextMenu).prop("keyboardAccess"), true);
+ });
+ it("should call focusFirst when keyboardAccess is true", () => {
+ const options = [{ label: "item1", first: true }];
+ const wrapper = mountWithProps(options);
+ const focusFirst = sandbox.spy(_ContextMenuItem.prototype, "focusFirst");
+ wrapper
+ .find(ContextMenuButton)
+ .setState({ showContextMenu: true, contextMenuKeyboard: true });
+ assert.calledOnce(focusFirst);
+ });
+});
+
+describe("<ContextMenu>", () => {
+ function mountWithProps(props) {
+ const store = createStore(combineReducers(reducers), INITIAL_STATE);
+ return mount(
+ <Provider store={store}>
+ <ContextMenu {...props} />
+ </Provider>
+ );
+ }
+
+ it("should render all the options provided", () => {
+ const options = [
+ { label: "item1" },
+ { type: "separator" },
+ { label: "item2" },
+ ];
+ const wrapper = shallow(
+ <ContextMenu {...DEFAULT_PROPS} options={options} />
+ );
+ assert.lengthOf(wrapper.find(".context-menu-list").children(), 3);
+ });
+ it("should not add a link for a separator", () => {
+ const options = [{ label: "item1" }, { type: "separator" }];
+ const wrapper = shallow(
+ <ContextMenu {...DEFAULT_PROPS} options={options} />
+ );
+ assert.lengthOf(wrapper.find(".separator"), 1);
+ });
+ it("should add a link for all types that are not separators", () => {
+ const options = [{ label: "item1" }, { type: "separator" }];
+ const wrapper = shallow(
+ <ContextMenu {...DEFAULT_PROPS} options={options} />
+ );
+ assert.lengthOf(wrapper.find(ContextMenuItem), 1);
+ });
+ it("should not add an icon to any items", () => {
+ const props = Object.assign({}, DEFAULT_PROPS, {
+ options: [{ label: "item1", icon: "icon1" }, { type: "separator" }],
+ });
+ const wrapper = mountWithProps(props);
+ assert.lengthOf(wrapper.find(".icon-icon1"), 0);
+ });
+ it("should be tabbable", () => {
+ const props = {
+ options: [{ label: "item1", icon: "icon1" }, { type: "separator" }],
+ };
+ const wrapper = mountWithProps(props);
+ assert.equal(
+ wrapper.find(".context-menu-item").props().role,
+ "presentation"
+ );
+ });
+ it("should call onUpdate with false when an option is clicked", () => {
+ const onUpdate = sinon.spy();
+ const onClick = sinon.spy();
+ const props = Object.assign({}, DEFAULT_PROPS, {
+ onUpdate,
+ options: [{ label: "item1", onClick }],
+ });
+ const wrapper = mountWithProps(props);
+ wrapper.find(".context-menu-item button").simulate("click");
+ assert.calledOnce(onUpdate);
+ assert.calledOnce(onClick);
+ });
+ it("should not have disabled className by default", () => {
+ const props = Object.assign({}, DEFAULT_PROPS, {
+ options: [{ label: "item1", icon: "icon1" }, { type: "separator" }],
+ });
+ const wrapper = mountWithProps(props);
+ assert.lengthOf(wrapper.find(".context-menu-item a.disabled"), 0);
+ });
+ it("should add disabled className to any disabled options", () => {
+ const options = [
+ { label: "item1", icon: "icon1", disabled: true },
+ { type: "separator" },
+ ];
+ const props = Object.assign({}, DEFAULT_PROPS, { options });
+ const wrapper = mountWithProps(props);
+ assert.lengthOf(wrapper.find(".context-menu-item button.disabled"), 1);
+ });
+ it("should have the context-menu-item class", () => {
+ const options = [{ label: "item1", icon: "icon1" }];
+ const props = Object.assign({}, DEFAULT_PROPS, { options });
+ const wrapper = mountWithProps(props);
+ assert.lengthOf(wrapper.find(".context-menu-item"), 1);
+ });
+ it("should call onClick when onKeyDown is called with Enter", () => {
+ const onClick = sinon.spy();
+ const props = Object.assign({}, DEFAULT_PROPS, {
+ options: [{ label: "item1", onClick }],
+ });
+ const wrapper = mountWithProps(props);
+ wrapper
+ .find(".context-menu-item button")
+ .simulate("keydown", { key: "Enter" });
+ assert.calledOnce(onClick);
+ });
+ it("should call focusSibling when onKeyDown is called with ArrowUp", () => {
+ const props = Object.assign({}, DEFAULT_PROPS, {
+ options: [{ label: "item1" }],
+ });
+ const wrapper = mountWithProps(props);
+ const focusSibling = sinon.stub(
+ wrapper.find(_ContextMenuItem).instance(),
+ "focusSibling"
+ );
+ wrapper
+ .find(".context-menu-item button")
+ .simulate("keydown", { key: "ArrowUp" });
+ assert.calledOnce(focusSibling);
+ });
+ it("should call focusSibling when onKeyDown is called with ArrowDown", () => {
+ const props = Object.assign({}, DEFAULT_PROPS, {
+ options: [{ label: "item1" }],
+ });
+ const wrapper = mountWithProps(props);
+ const focusSibling = sinon.stub(
+ wrapper.find(_ContextMenuItem).instance(),
+ "focusSibling"
+ );
+ wrapper
+ .find(".context-menu-item button")
+ .simulate("keydown", { key: "ArrowDown" });
+ assert.calledOnce(focusSibling);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx
new file mode 100644
index 0000000000..b4cf2b1261
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx
@@ -0,0 +1,72 @@
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import { ContentSection } from "content-src/components/CustomizeMenu/ContentSection/ContentSection";
+import { mount } from "enzyme";
+import React from "react";
+
+const DEFAULT_PROPS = {
+ enabledSections: {
+ pocketEnabled: true,
+ topSitesEnabled: true,
+ },
+ mayHaveSponsoredTopSites: true,
+ mayHaveSponsoredStories: true,
+ pocketRegion: true,
+ dispatch: sinon.stub(),
+ setPref: sinon.stub(),
+};
+
+describe("ContentSection", () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = mount(<ContentSection {...DEFAULT_PROPS} />);
+ });
+
+ it("should render the component", () => {
+ assert.ok(wrapper.exists());
+ });
+
+ it("should look for an eventSource attribute and dispatch an event for INPUT", () => {
+ wrapper.instance().onPreferenceSelect({
+ target: {
+ nodeName: "INPUT",
+ checked: true,
+ getAttribute: eventSource =>
+ eventSource === "eventSource" ? "foo" : null,
+ },
+ });
+
+ assert.calledWith(
+ DEFAULT_PROPS.dispatch,
+ ac.UserEvent({
+ event: "PREF_CHANGED",
+ source: "foo",
+ value: { status: true, menu_source: "CUSTOMIZE_MENU" },
+ })
+ );
+ wrapper.unmount();
+ });
+
+ it("should have eventSource attributes on relevent pref changing inputs", () => {
+ wrapper = mount(<ContentSection {...DEFAULT_PROPS} />);
+ assert.equal(
+ wrapper.find("#shortcuts-toggle").prop("eventSource"),
+ "TOP_SITES"
+ );
+ assert.equal(
+ wrapper.find("#sponsored-shortcuts").prop("eventSource"),
+ "SPONSORED_TOP_SITES"
+ );
+ assert.equal(
+ wrapper.find("#pocket-toggle").prop("eventSource"),
+ "TOP_STORIES"
+ );
+ assert.equal(
+ wrapper.find("#sponsored-pocket").prop("eventSource"),
+ "POCKET_SPOCS"
+ );
+ assert.equal(
+ wrapper.find("#highlights-toggle").prop("eventSource"),
+ "HIGHLIGHTS"
+ );
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamBase.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamBase.test.jsx
new file mode 100644
index 0000000000..7720e07327
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamBase.test.jsx
@@ -0,0 +1,313 @@
+import {
+ _DiscoveryStreamBase as DiscoveryStreamBase,
+ isAllowedCSS,
+} from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase";
+import { GlobalOverrider } from "test/unit/utils";
+import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
+import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
+import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage";
+import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
+import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation";
+import React from "react";
+import { shallow } from "enzyme";
+import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle";
+import { TopSites } from "content-src/components/TopSites/TopSites";
+
+describe("<isAllowedCSS>", () => {
+ it("should allow colors", () => {
+ assert.isTrue(isAllowedCSS("color", "red"));
+ });
+
+ it("should allow chrome urls", () => {
+ assert.isTrue(
+ isAllowedCSS(
+ "background-image",
+ `url("chrome://global/skin/icons/info.svg")`
+ )
+ );
+ });
+
+ it("should allow chrome urls", () => {
+ assert.isTrue(
+ isAllowedCSS(
+ "background-image",
+ `url("chrome://browser/skin/history.svg")`
+ )
+ );
+ });
+
+ it("should allow allowed https urls", () => {
+ assert.isTrue(
+ isAllowedCSS(
+ "background-image",
+ `url("https://img-getpocket.cdn.mozilla.net/media/image.png")`
+ )
+ );
+ });
+
+ it("should disallow other https urls", () => {
+ assert.isFalse(
+ isAllowedCSS(
+ "background-image",
+ `url("https://mozilla.org/media/image.png")`
+ )
+ );
+ });
+
+ it("should disallow other protocols", () => {
+ assert.isFalse(
+ isAllowedCSS(
+ "background-image",
+ `url("ftp://mozilla.org/media/image.png")`
+ )
+ );
+ });
+
+ it("should allow allowed multiple valid urls", () => {
+ assert.isTrue(
+ isAllowedCSS(
+ "background-image",
+ `url("https://img-getpocket.cdn.mozilla.net/media/image.png"), url("chrome://browser/skin/history.svg")`
+ )
+ );
+ });
+
+ it("should disallow if any invaild", () => {
+ assert.isFalse(
+ isAllowedCSS(
+ "background-image",
+ `url("chrome://browser/skin/history.svg"), url("ftp://mozilla.org/media/image.png")`
+ )
+ );
+ });
+});
+
+describe("<DiscoveryStreamBase>", () => {
+ let wrapper;
+ let globals;
+ let sandbox;
+
+ function mountComponent(props = {}) {
+ const defaultProps = {
+ config: { collapsible: true },
+ layout: [],
+ feeds: { loaded: true },
+ spocs: {
+ loaded: true,
+ data: { spocs: null },
+ },
+ ...props,
+ };
+ return shallow(
+ <DiscoveryStreamBase
+ locale="en-US"
+ DiscoveryStream={defaultProps}
+ Prefs={{
+ values: {
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ "feeds.topsites": true,
+ },
+ }}
+ App={{
+ locale: "en-US",
+ }}
+ document={{
+ documentElement: { lang: "en-US" },
+ }}
+ Sections={[
+ {
+ id: "topstories",
+ learnMore: { link: {} },
+ pref: {},
+ },
+ ]}
+ />
+ );
+ }
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ wrapper = mountComponent();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ it("should render something if spocs are not loaded", () => {
+ wrapper = mountComponent({
+ spocs: { loaded: false, data: { spocs: null } },
+ });
+
+ assert.notEqual(wrapper.type(), null);
+ });
+
+ it("should render something if feeds are not loaded", () => {
+ wrapper = mountComponent({ feeds: { loaded: false } });
+
+ assert.notEqual(wrapper.type(), null);
+ });
+
+ it("should render nothing with no layout", () => {
+ assert.ok(wrapper.exists());
+ assert.isEmpty(wrapper.children());
+ });
+
+ it("should render a HorizontalRule component", () => {
+ wrapper = mountComponent({
+ layout: [{ components: [{ type: "HorizontalRule" }] }],
+ });
+
+ assert.equal(
+ wrapper.find(".ds-column-grid div").children().at(0).type(),
+ HorizontalRule
+ );
+ });
+
+ it("should render a CardGrid component", () => {
+ wrapper = mountComponent({
+ layout: [{ components: [{ properties: {}, type: "CardGrid" }] }],
+ });
+
+ assert.equal(
+ wrapper.find(".ds-column-grid div").children().at(0).type(),
+ CardGrid
+ );
+ });
+
+ it("should render a Navigation component", () => {
+ wrapper = mountComponent({
+ layout: [{ components: [{ properties: {}, type: "Navigation" }] }],
+ });
+
+ assert.equal(
+ wrapper.find(".ds-column-grid div").children().at(0).type(),
+ Navigation
+ );
+ });
+
+ it("should render nothing if there was only a Message", () => {
+ wrapper = mountComponent({
+ layout: [
+ { components: [{ header: {}, properties: {}, type: "Message" }] },
+ ],
+ });
+
+ assert.isEmpty(wrapper.children());
+ });
+
+ it("should render a regular Message when not collapsible", () => {
+ wrapper = mountComponent({
+ config: { collapsible: false },
+ layout: [
+ { components: [{ header: {}, properties: {}, type: "Message" }] },
+ ],
+ });
+
+ assert.equal(
+ wrapper.find(".ds-column-grid div").children().at(0).type(),
+ DSMessage
+ );
+ });
+
+ it("should convert first Message component to CollapsibleSection", () => {
+ wrapper = mountComponent({
+ layout: [
+ {
+ components: [
+ { header: {}, properties: {}, type: "Message" },
+ { type: "HorizontalRule" },
+ ],
+ },
+ ],
+ });
+
+ assert.equal(wrapper.children().at(0).type(), CollapsibleSection);
+ assert.equal(wrapper.children().at(0).props().eventSource, "CARDGRID");
+ });
+
+ it("should render a Message component", () => {
+ wrapper = mountComponent({
+ layout: [
+ {
+ components: [
+ { header: {}, type: "Message" },
+ { properties: {}, type: "Message" },
+ ],
+ },
+ ],
+ });
+
+ assert.equal(
+ wrapper.find(".ds-column-grid div").children().at(0).type(),
+ DSMessage
+ );
+ });
+
+ it("should render a SectionTitle component", () => {
+ wrapper = mountComponent({
+ layout: [{ components: [{ properties: {}, type: "SectionTitle" }] }],
+ });
+
+ assert.equal(
+ wrapper.find(".ds-column-grid div").children().at(0).type(),
+ SectionTitle
+ );
+ });
+
+ it("should render TopSites", () => {
+ wrapper = mountComponent({
+ layout: [{ components: [{ properties: {}, type: "TopSites" }] }],
+ });
+
+ assert.equal(
+ wrapper
+ .find(".ds-column-grid div")
+ .find(".ds-top-sites")
+ .children()
+ .at(0)
+ .type(),
+ TopSites
+ );
+ });
+
+ describe("#onStyleMount", () => {
+ let parseStub;
+
+ beforeEach(() => {
+ parseStub = sandbox.stub();
+ globals.set("JSON", { parse: parseStub });
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ it("should return if no style", () => {
+ assert.isUndefined(wrapper.instance().onStyleMount());
+ assert.notCalled(parseStub);
+ });
+
+ it("should insert rules", () => {
+ const sheetStub = { insertRule: sandbox.stub(), cssRules: [{}] };
+ parseStub.returns([
+ [
+ null,
+ {
+ ".ds-message": "margin-bottom: -20px",
+ },
+ null,
+ null,
+ ],
+ ]);
+ wrapper.instance().onStyleMount({ sheet: sheetStub, dataset: {} });
+
+ assert.calledOnce(sheetStub.insertRule);
+ assert.calledWithExactly(sheetStub.insertRule, "DUMMY#CSS.SELECTOR {}");
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx
new file mode 100644
index 0000000000..418a731ba1
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx
@@ -0,0 +1,354 @@
+import {
+ _CardGrid as CardGrid,
+ IntersectionObserver,
+ RecentSavesContainer,
+ OnboardingExperience,
+ DSSubHeader,
+} from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
+import { combineReducers, createStore } from "redux";
+import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
+import { Provider } from "react-redux";
+import {
+ DSCard,
+ PlaceholderDSCard,
+} from "content-src/components/DiscoveryStreamComponents/DSCard/DSCard";
+import { TopicsWidget } from "content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget";
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import React from "react";
+import { shallow, mount } from "enzyme";
+
+// Wrap this around any component that uses useSelector,
+// or any mount that uses a child that uses redux.
+function WrapWithProvider({ children, state = INITIAL_STATE }) {
+ let store = createStore(combineReducers(reducers), state);
+ return <Provider store={store}>{children}</Provider>;
+}
+
+describe("<CardGrid>", () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow(
+ <CardGrid
+ Prefs={INITIAL_STATE.Prefs}
+ DiscoveryStream={INITIAL_STATE.DiscoveryStream}
+ />
+ );
+ });
+
+ it("should render an empty div", () => {
+ assert.ok(wrapper.exists());
+ assert.lengthOf(wrapper.children(), 0);
+ });
+
+ it("should render DSCards", () => {
+ wrapper.setProps({ items: 2, data: { recommendations: [{}, {}] } });
+
+ assert.lengthOf(wrapper.find(".ds-card-grid").children(), 2);
+ assert.equal(wrapper.find(".ds-card-grid").children().at(0).type(), DSCard);
+ });
+
+ it("should add 4 card classname to card grid", () => {
+ wrapper.setProps({
+ fourCardLayout: true,
+ data: { recommendations: [{}, {}] },
+ });
+
+ assert.ok(wrapper.find(".ds-card-grid-four-card-variant").exists());
+ });
+
+ it("should add no description classname to card grid", () => {
+ wrapper.setProps({
+ hideCardBackground: true,
+ data: { recommendations: [{}, {}] },
+ });
+
+ assert.ok(wrapper.find(".ds-card-grid-hide-background").exists());
+ });
+
+ it("should render sub header in the middle of the card grid for both regular and compact", () => {
+ const commonProps = {
+ essentialReadsHeader: true,
+ editorsPicksHeader: true,
+ items: 12,
+ data: {
+ recommendations: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}],
+ },
+ Prefs: INITIAL_STATE.Prefs,
+ DiscoveryStream: INITIAL_STATE.DiscoveryStream,
+ };
+ wrapper = mount(
+ <WrapWithProvider>
+ <CardGrid {...commonProps} />
+ </WrapWithProvider>
+ );
+
+ assert.ok(wrapper.find(DSSubHeader).exists());
+
+ wrapper.setProps({
+ compact: true,
+ });
+ wrapper = mount(
+ <WrapWithProvider>
+ <CardGrid {...commonProps} compact={true} />
+ </WrapWithProvider>
+ );
+
+ assert.ok(wrapper.find(DSSubHeader).exists());
+ });
+
+ it("should add/hide description classname to card grid", () => {
+ wrapper.setProps({
+ data: { recommendations: [{}, {}] },
+ });
+
+ assert.ok(wrapper.find(".ds-card-grid-include-descriptions").exists());
+
+ wrapper.setProps({
+ hideDescriptions: true,
+ data: { recommendations: [{}, {}] },
+ });
+
+ assert.ok(!wrapper.find(".ds-card-grid-include-descriptions").exists());
+ });
+
+ it("should create a widget card", () => {
+ wrapper.setProps({
+ widgets: {
+ positions: [{ index: 1 }],
+ data: [{ type: "TopicsWidget" }],
+ },
+ data: {
+ recommendations: [{}, {}, {}],
+ },
+ });
+
+ assert.ok(wrapper.find(TopicsWidget).exists());
+ });
+});
+
+// Build IntersectionObserver class with the arg `entries` for the intersect callback.
+function buildIntersectionObserver(entries) {
+ return class {
+ constructor(callback) {
+ this.callback = callback;
+ }
+
+ observe() {
+ this.callback(entries);
+ }
+
+ unobserve() {}
+
+ disconnect() {}
+ };
+}
+
+describe("<IntersectionObserver>", () => {
+ let wrapper;
+ let fakeWindow;
+ let intersectEntries;
+
+ beforeEach(() => {
+ intersectEntries = [{ isIntersecting: true }];
+ fakeWindow = {
+ IntersectionObserver: buildIntersectionObserver(intersectEntries),
+ };
+ wrapper = mount(<IntersectionObserver windowObj={fakeWindow} />);
+ });
+
+ it("should render an empty div", () => {
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.children().at(0).type(), "div");
+ });
+
+ it("should fire onIntersecting", () => {
+ const onIntersecting = sinon.stub();
+ wrapper = mount(
+ <IntersectionObserver
+ windowObj={fakeWindow}
+ onIntersecting={onIntersecting}
+ />
+ );
+ assert.calledOnce(onIntersecting);
+ });
+});
+
+describe("<RecentSavesContainer>", () => {
+ let wrapper;
+ let fakeWindow;
+ let intersectEntries;
+ let dispatch;
+
+ beforeEach(() => {
+ dispatch = sinon.stub();
+ intersectEntries = [{ isIntersecting: true }];
+ fakeWindow = {
+ IntersectionObserver: buildIntersectionObserver(intersectEntries),
+ };
+ wrapper = mount(
+ <WrapWithProvider
+ state={{
+ DiscoveryStream: {
+ isUserLoggedIn: true,
+ recentSavesData: [
+ {
+ resolved_id: "resolved_id",
+ top_image_url: "top_image_url",
+ title: "title",
+ resolved_url: "https://resolved_url",
+ domain: "domain",
+ excerpt: "excerpt",
+ },
+ ],
+ experimentData: {
+ utmSource: "utmSource",
+ utmContent: "utmContent",
+ utmCampaign: "utmCampaign",
+ },
+ },
+ }}
+ >
+ <RecentSavesContainer
+ gridClassName="ds-card-grid"
+ windowObj={fakeWindow}
+ dispatch={dispatch}
+ />
+ </WrapWithProvider>
+ ).find(RecentSavesContainer);
+ });
+
+ it("should render an IntersectionObserver when not visible", () => {
+ intersectEntries = [{ isIntersecting: false }];
+ fakeWindow = {
+ IntersectionObserver: buildIntersectionObserver(intersectEntries),
+ };
+ wrapper = mount(
+ <WrapWithProvider>
+ <RecentSavesContainer windowObj={fakeWindow} dispatch={dispatch} />
+ </WrapWithProvider>
+ ).find(RecentSavesContainer);
+
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(IntersectionObserver).exists());
+ });
+
+ it("should render nothing if visible until we log in", () => {
+ assert.ok(!wrapper.find(IntersectionObserver).exists());
+ assert.calledOnce(dispatch);
+ assert.calledWith(
+ dispatch,
+ ac.AlsoToMain({
+ type: at.DISCOVERY_STREAM_POCKET_STATE_INIT,
+ })
+ );
+ });
+
+ it("should render a grid if visible and logged in", () => {
+ assert.lengthOf(wrapper.find(".ds-card-grid"), 1);
+ assert.lengthOf(wrapper.find(DSSubHeader), 1);
+ assert.lengthOf(wrapper.find(PlaceholderDSCard), 2);
+ assert.lengthOf(wrapper.find(DSCard), 3);
+ });
+
+ it("should render a my list link with proper utm params", () => {
+ assert.equal(
+ wrapper.find(".section-sub-link").at(0).prop("url"),
+ "https://getpocket.com/a?utm_source=utmSource&utm_content=utmContent&utm_campaign=utmCampaign"
+ );
+ });
+
+ it("should fire a UserEvent for my list clicks", () => {
+ wrapper.find(".section-sub-link").at(0).simulate("click");
+ assert.calledWith(
+ dispatch,
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: `CARDGRID_RECENT_SAVES_VIEW_LIST`,
+ })
+ );
+ });
+});
+
+describe("<OnboardingExperience>", () => {
+ let wrapper;
+ let fakeWindow;
+ let intersectEntries;
+ let dispatch;
+ let resizeCallback;
+
+ let fakeResizeObserver = class {
+ constructor(callback) {
+ resizeCallback = callback;
+ }
+
+ observe() {}
+
+ unobserve() {}
+
+ disconnect() {}
+ };
+
+ beforeEach(() => {
+ dispatch = sinon.stub();
+ intersectEntries = [{ isIntersecting: true, intersectionRatio: 1 }];
+ fakeWindow = {
+ ResizeObserver: fakeResizeObserver,
+ IntersectionObserver: buildIntersectionObserver(intersectEntries),
+ document: {
+ visibilityState: "visible",
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ },
+ };
+ wrapper = mount(
+ <WrapWithProvider state={{}}>
+ <OnboardingExperience windowObj={fakeWindow} dispatch={dispatch} />
+ </WrapWithProvider>
+ ).find(OnboardingExperience);
+ });
+
+ it("should render a ds-onboarding", () => {
+ assert.ok(wrapper.exists());
+ assert.lengthOf(wrapper.find(".ds-onboarding"), 1);
+ });
+
+ it("should dismiss on dismiss click", () => {
+ wrapper.find(".ds-dismiss-button").simulate("click");
+
+ assert.calledWith(
+ dispatch,
+ ac.DiscoveryStreamUserEvent({
+ event: "BLOCK",
+ source: "POCKET_ONBOARDING",
+ })
+ );
+ assert.calledWith(
+ dispatch,
+ ac.SetPref("discoverystream.onboardingExperience.dismissed", true)
+ );
+ assert.equal(wrapper.getDOMNode().style["max-height"], "0px");
+ assert.equal(wrapper.getDOMNode().style.opacity, "0");
+ });
+
+ it("should update max-height on resize", () => {
+ sinon
+ .stub(wrapper.find(".ds-onboarding-ref").getDOMNode(), "offsetHeight")
+ .get(() => 123);
+ resizeCallback();
+ assert.equal(wrapper.getDOMNode().style["max-height"], "123px");
+ });
+
+ it("should fire intersection events", () => {
+ assert.calledWith(
+ dispatch,
+ ac.DiscoveryStreamUserEvent({
+ event: "IMPRESSION",
+ source: "POCKET_ONBOARDING",
+ })
+ );
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx
new file mode 100644
index 0000000000..3721508a59
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx
@@ -0,0 +1,134 @@
+import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid";
+import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<CollectionCardGrid>", () => {
+ let wrapper;
+ let sandbox;
+ let dispatchStub;
+ const initialSpocs = [
+ { id: 123, url: "123" },
+ { id: 456, url: "456" },
+ { id: 789, url: "789" },
+ ];
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ dispatchStub = sandbox.stub();
+ wrapper = shallow(
+ <CollectionCardGrid
+ dispatch={dispatchStub}
+ type="COLLECTIONCARDGRID"
+ placement={{
+ name: "spocs",
+ }}
+ data={{
+ spocs: initialSpocs,
+ }}
+ spocs={{
+ data: {
+ spocs: {
+ title: "title",
+ context: "context",
+ items: initialSpocs,
+ },
+ },
+ }}
+ />
+ );
+ });
+
+ it("should render an empty div", () => {
+ wrapper = shallow(<CollectionCardGrid />);
+ assert.ok(wrapper.exists());
+ assert.ok(!wrapper.exists(".ds-collection-card-grid"));
+ });
+
+ it("should render a CardGrid", () => {
+ assert.lengthOf(wrapper.find(".ds-collection-card-grid").children(), 1);
+ assert.equal(
+ wrapper.find(".ds-collection-card-grid").children().at(0).type(),
+ CardGrid
+ );
+ });
+
+ it("should inject spocs in every CardGrid rec position", () => {
+ assert.lengthOf(
+ wrapper.find(".ds-collection-card-grid").children().at(0).props().data
+ .recommendations,
+ 3
+ );
+ });
+
+ it("should pass along title and context to CardGrid", () => {
+ assert.equal(
+ wrapper.find(".ds-collection-card-grid").children().at(0).props().title,
+ "title"
+ );
+
+ assert.equal(
+ wrapper.find(".ds-collection-card-grid").children().at(0).props().context,
+ "context"
+ );
+ });
+
+ it("should render nothing without a title", () => {
+ wrapper = shallow(
+ <CollectionCardGrid
+ dispatch={dispatchStub}
+ placement={{
+ name: "spocs",
+ }}
+ data={{
+ spocs: initialSpocs,
+ }}
+ spocs={{
+ data: {
+ spocs: {
+ title: "",
+ context: "context",
+ items: initialSpocs,
+ },
+ },
+ }}
+ />
+ );
+
+ assert.ok(wrapper.exists());
+ assert.ok(!wrapper.exists(".ds-collection-card-grid"));
+ });
+
+ it("should dispatch telemety events on dismiss", () => {
+ wrapper.instance().onDismissClick();
+
+ const firstCall = dispatchStub.getCall(0);
+ const secondCall = dispatchStub.getCall(1);
+ const thirdCall = dispatchStub.getCall(2);
+
+ assert.equal(firstCall.args[0].type, "BLOCK_URL");
+ assert.deepEqual(firstCall.args[0].data, [
+ { url: "123", pocket_id: undefined, isSponsoredTopSite: undefined },
+ { url: "456", pocket_id: undefined, isSponsoredTopSite: undefined },
+ { url: "789", pocket_id: undefined, isSponsoredTopSite: undefined },
+ ]);
+
+ assert.equal(secondCall.args[0].type, "DISCOVERY_STREAM_USER_EVENT");
+ assert.deepEqual(secondCall.args[0].data, {
+ event: "BLOCK",
+ source: "COLLECTIONCARDGRID",
+ action_position: 0,
+ });
+
+ assert.equal(thirdCall.args[0].type, "TELEMETRY_IMPRESSION_STATS");
+ assert.deepEqual(thirdCall.args[0].data, {
+ source: "COLLECTIONCARDGRID",
+ block: 0,
+ tiles: [
+ { id: 123, pos: 0 },
+ { id: 456, pos: 1 },
+ { id: 789, pos: 2 },
+ ],
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx
new file mode 100644
index 0000000000..2ebba1d4e5
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx
@@ -0,0 +1,544 @@
+import {
+ _DSCard as DSCard,
+ readTimeFromWordCount,
+ DSSource,
+ DefaultMeta,
+ PlaceholderDSCard,
+} from "content-src/components/DiscoveryStreamComponents/DSCard/DSCard";
+import {
+ DSContextFooter,
+ StatusMessage,
+ SponsorLabel,
+} from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter";
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { DSLinkMenu } from "content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu";
+import React from "react";
+import { INITIAL_STATE } from "common/Reducers.sys.mjs";
+import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor";
+import { shallow, mount } from "enzyme";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+
+const DEFAULT_PROPS = {
+ url: "about:robots",
+ title: "title",
+ App: {
+ isForStartupCache: false,
+ },
+ DiscoveryStream: INITIAL_STATE.DiscoveryStream,
+};
+
+describe("<DSCard>", () => {
+ let wrapper;
+ let sandbox;
+ let dispatch;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ dispatch = sandbox.stub();
+ wrapper = shallow(<DSCard dispatch={dispatch} {...DEFAULT_PROPS} />);
+ wrapper.setState({ isSeen: true });
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".ds-card"));
+ });
+
+ it("should render a SafeAnchor", () => {
+ wrapper.setProps({ url: "https://foo.com" });
+
+ assert.equal(wrapper.children().at(0).type(), SafeAnchor);
+ assert.propertyVal(
+ wrapper.children().at(0).props(),
+ "url",
+ "https://foo.com"
+ );
+ });
+
+ it("should pass onLinkClick prop", () => {
+ assert.propertyVal(
+ wrapper.children().at(0).props(),
+ "onLinkClick",
+ wrapper.instance().onLinkClick
+ );
+ });
+
+ it("should render DSLinkMenu", () => {
+ assert.equal(wrapper.children().at(1).type(), DSLinkMenu);
+ });
+
+ it("should start with no .active class", () => {
+ assert.equal(wrapper.find(".active").length, 0);
+ });
+
+ it("should render badges for pocket, bookmark when not a spoc element ", () => {
+ wrapper = mount(<DSCard context_type="bookmark" {...DEFAULT_PROPS} />);
+ wrapper.setState({ isSeen: true });
+ const contextFooter = wrapper.find(DSContextFooter);
+
+ assert.lengthOf(contextFooter.find(StatusMessage), 1);
+ });
+
+ it("should render Sponsored Context for a spoc element", () => {
+ const context = "Sponsored by Foo";
+ wrapper = mount(
+ <DSCard context_type="bookmark" context={context} {...DEFAULT_PROPS} />
+ );
+ wrapper.setState({ isSeen: true });
+ const contextFooter = wrapper.find(DSContextFooter);
+
+ assert.lengthOf(contextFooter.find(StatusMessage), 0);
+ assert.equal(contextFooter.find(".story-sponsored-label").text(), context);
+ });
+
+ it("should render time to read", () => {
+ const discoveryStream = {
+ ...INITIAL_STATE.DiscoveryStream,
+ readTime: true,
+ };
+ wrapper = mount(
+ <DSCard
+ time_to_read={4}
+ {...DEFAULT_PROPS}
+ DiscoveryStream={discoveryStream}
+ />
+ );
+ wrapper.setState({ isSeen: true });
+ const defaultMeta = wrapper.find(DefaultMeta);
+ assert.lengthOf(defaultMeta, 1);
+ assert.equal(defaultMeta.props().timeToRead, 4);
+ });
+
+ it("should not show save to pocket button for spocs", () => {
+ wrapper.setProps({
+ id: "fooidx",
+ pos: 1,
+ type: "foo",
+ flightId: 12345,
+ saveToPocketCard: true,
+ });
+
+ let stpButton = wrapper.find(".card-stp-button");
+
+ assert.lengthOf(stpButton, 0);
+ });
+
+ it("should show save to pocket button for non-spocs", () => {
+ wrapper.setProps({
+ id: "fooidx",
+ pos: 1,
+ type: "foo",
+ saveToPocketCard: true,
+ });
+
+ let stpButton = wrapper.find(".card-stp-button");
+
+ assert.lengthOf(stpButton, 1);
+ });
+
+ describe("onLinkClick", () => {
+ let fakeWindow;
+
+ beforeEach(() => {
+ fakeWindow = {
+ requestIdleCallback: sinon.stub().returns(1),
+ cancelIdleCallback: sinon.stub(),
+ innerWidth: 1000,
+ innerHeight: 900,
+ };
+ wrapper = mount(
+ <DSCard {...DEFAULT_PROPS} dispatch={dispatch} windowObj={fakeWindow} />
+ );
+ });
+
+ it("should call dispatch with the correct events", () => {
+ wrapper.setProps({ id: "fooidx", pos: 1, type: "foo" });
+
+ wrapper.instance().onLinkClick();
+
+ assert.calledTwice(dispatch);
+ assert.calledWith(
+ dispatch,
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "FOO",
+ action_position: 1,
+ value: { card_type: "organic" },
+ })
+ );
+ assert.calledWith(
+ dispatch,
+ ac.ImpressionStats({
+ click: 0,
+ source: "FOO",
+ tiles: [{ id: "fooidx", pos: 1, type: "organic" }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ })
+ );
+ });
+
+ it("should set the right card_type on spocs", () => {
+ wrapper.setProps({ id: "fooidx", pos: 1, type: "foo", flightId: 12345 });
+
+ wrapper.instance().onLinkClick();
+
+ assert.calledTwice(dispatch);
+ assert.calledWith(
+ dispatch,
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "FOO",
+ action_position: 1,
+ value: { card_type: "spoc" },
+ })
+ );
+ assert.calledWith(
+ dispatch,
+ ac.ImpressionStats({
+ click: 0,
+ source: "FOO",
+ tiles: [{ id: "fooidx", pos: 1, type: "spoc" }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ })
+ );
+ });
+
+ it("should call dispatch with a shim", () => {
+ wrapper.setProps({
+ id: "fooidx",
+ pos: 1,
+ type: "foo",
+ shim: {
+ click: "click shim",
+ },
+ });
+
+ wrapper.instance().onLinkClick();
+
+ assert.calledTwice(dispatch);
+ assert.calledWith(
+ dispatch,
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "FOO",
+ action_position: 1,
+ value: { card_type: "organic" },
+ })
+ );
+ assert.calledWith(
+ dispatch,
+ ac.ImpressionStats({
+ click: 0,
+ source: "FOO",
+ tiles: [
+ { id: "fooidx", pos: 1, shim: "click shim", type: "organic" },
+ ],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ })
+ );
+ });
+ });
+
+ describe("DSCard with CTA", () => {
+ beforeEach(() => {
+ wrapper = mount(<DSCard {...DEFAULT_PROPS} />);
+ wrapper.setState({ isSeen: true });
+ });
+
+ it("should render Default Meta", () => {
+ const default_meta = wrapper.find(DefaultMeta);
+ assert.ok(default_meta.exists());
+ });
+ });
+
+ describe("DSCard with Intersection Observer", () => {
+ beforeEach(() => {
+ wrapper = shallow(<DSCard {...DEFAULT_PROPS} />);
+ });
+
+ it("should render card when seen", () => {
+ let card = wrapper.find("div.ds-card.placeholder");
+ assert.lengthOf(card, 1);
+
+ wrapper.instance().observer = {
+ unobserve: sandbox.stub(),
+ };
+ wrapper.instance().placeholderElement = "element";
+
+ wrapper.instance().onSeen([
+ {
+ isIntersecting: true,
+ },
+ ]);
+
+ assert.isTrue(wrapper.instance().state.isSeen);
+ card = wrapper.find("div.ds-card.placeholder");
+ assert.lengthOf(card, 0);
+ assert.lengthOf(wrapper.find(SafeAnchor), 1);
+ assert.calledOnce(wrapper.instance().observer.unobserve);
+ assert.calledWith(wrapper.instance().observer.unobserve, "element");
+ });
+
+ it("should setup proper placholder ref for isSeen", () => {
+ wrapper.instance().setPlaceholderRef("element");
+ assert.equal(wrapper.instance().placeholderElement, "element");
+ });
+
+ it("should setup observer on componentDidMount", () => {
+ wrapper = mount(<DSCard {...DEFAULT_PROPS} />);
+ assert.isTrue(!!wrapper.instance().observer);
+ });
+ });
+
+ describe("DSCard with Idle Callback", () => {
+ let windowStub = {
+ requestIdleCallback: sinon.stub().returns(1),
+ cancelIdleCallback: sinon.stub(),
+ };
+ beforeEach(() => {
+ wrapper = shallow(<DSCard windowObj={windowStub} {...DEFAULT_PROPS} />);
+ });
+
+ it("should call requestIdleCallback on componentDidMount", () => {
+ assert.calledOnce(windowStub.requestIdleCallback);
+ });
+
+ it("should call cancelIdleCallback on componentWillUnmount", () => {
+ wrapper.instance().componentWillUnmount();
+ assert.calledOnce(windowStub.cancelIdleCallback);
+ });
+ });
+
+ describe("DSCard when rendered for about:home startup cache", () => {
+ beforeEach(() => {
+ const props = {
+ App: {
+ isForStartupCache: true,
+ },
+ DiscoveryStream: INITIAL_STATE.DiscoveryStream,
+ };
+ wrapper = mount(<DSCard {...props} />);
+ });
+
+ it("should be set as isSeen automatically", () => {
+ assert.isTrue(wrapper.instance().state.isSeen);
+ });
+ });
+
+ describe("DSCard onSaveClick", () => {
+ it("should fire telemetry for onSaveClick", () => {
+ wrapper.setProps({ id: "fooidx", pos: 1, type: "foo" });
+ wrapper.instance().onSaveClick();
+
+ assert.calledThrice(dispatch);
+ assert.calledWith(
+ dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_TO_POCKET,
+ data: { site: { url: "about:robots", title: "title" } },
+ })
+ );
+ assert.calledWith(
+ dispatch,
+ ac.DiscoveryStreamUserEvent({
+ event: "SAVE_TO_POCKET",
+ source: "CARDGRID_HOVER",
+ action_position: 1,
+ value: { card_type: "organic" },
+ })
+ );
+ assert.calledWith(
+ dispatch,
+ ac.ImpressionStats({
+ source: "CARDGRID_HOVER",
+ pocket: 0,
+ tiles: [
+ {
+ id: "fooidx",
+ pos: 1,
+ },
+ ],
+ })
+ );
+ });
+ });
+
+ describe("DSCard menu open states", () => {
+ let cardNode;
+ let fakeDocument;
+ let fakeWindow;
+
+ beforeEach(() => {
+ fakeDocument = { l10n: { translateFragment: sinon.stub() } };
+ fakeWindow = {
+ document: fakeDocument,
+ requestIdleCallback: sinon.stub().returns(1),
+ cancelIdleCallback: sinon.stub(),
+ };
+ wrapper = mount(<DSCard {...DEFAULT_PROPS} windowObj={fakeWindow} />);
+ wrapper.setState({ isSeen: true });
+ cardNode = wrapper.getDOMNode();
+ });
+
+ it("Should remove active on Menu Update", () => {
+ // Add active class name to DSCard wrapper
+ // to simulate menu open state
+ cardNode.classList.add("active");
+ assert.equal(
+ cardNode.className,
+ "ds-card ds-card-title-lines-3 ds-card-desc-lines-3 active"
+ );
+
+ wrapper.instance().onMenuUpdate(false);
+ wrapper.update();
+
+ assert.equal(
+ cardNode.className,
+ "ds-card ds-card-title-lines-3 ds-card-desc-lines-3"
+ );
+ });
+
+ it("Should add active on Menu Show", async () => {
+ await wrapper.instance().onMenuShow();
+ wrapper.update();
+ assert.equal(
+ cardNode.className,
+ "ds-card ds-card-title-lines-3 ds-card-desc-lines-3 active"
+ );
+ });
+
+ it("Should add last-item to support resized window", async () => {
+ fakeWindow.scrollMaxX = 20;
+ await wrapper.instance().onMenuShow();
+ wrapper.update();
+ assert.equal(
+ cardNode.className,
+ "ds-card ds-card-title-lines-3 ds-card-desc-lines-3 last-item active"
+ );
+ });
+
+ it("should remove .active and .last-item classes", () => {
+ const instance = wrapper.instance();
+ const remove = sinon.stub();
+ instance.contextMenuButtonHostElement = {
+ classList: { remove },
+ };
+ instance.onMenuUpdate();
+ assert.calledOnce(remove);
+ });
+
+ it("should add .active and .last-item classes", async () => {
+ const instance = wrapper.instance();
+ const add = sinon.stub();
+ instance.contextMenuButtonHostElement = {
+ classList: { add },
+ };
+ await instance.onMenuShow();
+ assert.calledOnce(add);
+ });
+ });
+});
+
+describe("<PlaceholderDSCard> component", () => {
+ it("should have placeholder prop", () => {
+ const wrapper = shallow(<PlaceholderDSCard />);
+ const placeholder = wrapper.prop("placeholder");
+ assert.isTrue(placeholder);
+ });
+
+ it("should contain placeholder div", () => {
+ const wrapper = shallow(<DSCard placeholder={true} {...DEFAULT_PROPS} />);
+ wrapper.setState({ isSeen: true });
+ const card = wrapper.find("div.ds-card.placeholder");
+ assert.lengthOf(card, 1);
+ });
+
+ it("should not be clickable", () => {
+ const wrapper = shallow(<DSCard placeholder={true} {...DEFAULT_PROPS} />);
+ wrapper.setState({ isSeen: true });
+ const anchor = wrapper.find("SafeAnchor.ds-card-link");
+ assert.lengthOf(anchor, 0);
+ });
+
+ it("should not have context menu", () => {
+ const wrapper = shallow(<DSCard placeholder={true} {...DEFAULT_PROPS} />);
+ wrapper.setState({ isSeen: true });
+ const linkMenu = wrapper.find(DSLinkMenu);
+ assert.lengthOf(linkMenu, 0);
+ });
+});
+
+describe("<DSSource> component", () => {
+ it("should return a default source without compact", () => {
+ const wrapper = shallow(<DSSource source="Mozilla" />);
+
+ let sourceElement = wrapper.find(".source");
+ assert.equal(sourceElement.text(), "Mozilla");
+ });
+ it("should return a default source with compact without a sponsor or time to read", () => {
+ const wrapper = shallow(<DSSource compact={true} source="Mozilla" />);
+
+ let sourceElement = wrapper.find(".source");
+ assert.equal(sourceElement.text(), "Mozilla");
+ });
+ it("should return a SponsorLabel with compact and a sponsor", () => {
+ const wrapper = shallow(
+ <DSSource newSponsoredLabel={true} sponsor="Mozilla" />
+ );
+ const sponsorLabel = wrapper.find(SponsorLabel);
+ assert.lengthOf(sponsorLabel, 1);
+ });
+ it("should return a time to read with compact and without a sponsor but with a time to read", () => {
+ const wrapper = shallow(
+ <DSSource compact={true} source="Mozilla" timeToRead="2000" />
+ );
+
+ let timeToRead = wrapper.find(".time-to-read");
+ assert.lengthOf(timeToRead, 1);
+
+ // Weirdly, we can test for the pressence of fluent, because time to read needs to be translated.
+ // This is also because we did a shallow render, that th contents of fluent would be empty anyway.
+ const fluentOrText = wrapper.find(FluentOrText);
+ assert.lengthOf(fluentOrText, 1);
+ });
+ it("should prioritize a SponsorLabel if for some reason it gets everything", () => {
+ const wrapper = shallow(
+ <DSSource
+ newSponsoredLabel={true}
+ sponsor="Mozilla"
+ source="Mozilla"
+ timeToRead="2000"
+ />
+ );
+ const sponsorLabel = wrapper.find(SponsorLabel);
+ assert.lengthOf(sponsorLabel, 1);
+ });
+});
+
+describe("readTimeFromWordCount function", () => {
+ it("should return proper read time", () => {
+ const result = readTimeFromWordCount(2000);
+ assert.equal(result, 10);
+ });
+ it("should return false with falsey word count", () => {
+ assert.isFalse(readTimeFromWordCount());
+ assert.isFalse(readTimeFromWordCount(0));
+ assert.isFalse(readTimeFromWordCount(""));
+ assert.isFalse(readTimeFromWordCount(null));
+ assert.isFalse(readTimeFromWordCount(undefined));
+ });
+ it("should return NaN with invalid word count", () => {
+ assert.isNaN(readTimeFromWordCount("zero"));
+ assert.isNaN(readTimeFromWordCount({}));
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx
new file mode 100644
index 0000000000..08ac7868ce
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx
@@ -0,0 +1,138 @@
+import {
+ DSContextFooter,
+ StatusMessage,
+ DSMessageFooter,
+} from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter";
+import React from "react";
+import { mount } from "enzyme";
+import { cardContextTypes } from "content-src/components/Card/types.js";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText.jsx";
+
+describe("<DSContextFooter>", () => {
+ let wrapper;
+ let sandbox;
+ const bookmarkBadge = "bookmark";
+ const removeBookmarkBadge = "removedBookmark";
+ const context = "Sponsored by Babel";
+ const sponsored_by_override = "Sponsored override";
+ const engagement = "Popular";
+
+ beforeEach(() => {
+ wrapper = mount(<DSContextFooter />);
+ sandbox = sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render", () => assert.isTrue(wrapper.exists()));
+ it("should not render an engagement status if display_engagement_labels is false", () => {
+ wrapper = mount(
+ <DSContextFooter
+ display_engagement_labels={false}
+ engagement={engagement}
+ />
+ );
+
+ const engagementLabel = wrapper.find(".story-view-count");
+ assert.equal(engagementLabel.length, 0);
+ });
+ it("should render a badge if a proper badge prop is passed", () => {
+ wrapper = mount(
+ <DSContextFooter context_type={bookmarkBadge} engagement={engagement} />
+ );
+ const { fluentID } = cardContextTypes[bookmarkBadge];
+
+ assert.lengthOf(wrapper.find(".story-view-count"), 0);
+ const statusLabel = wrapper.find(".story-context-label");
+ assert.equal(statusLabel.prop("data-l10n-id"), fluentID);
+ });
+ it("should only render a sponsored context if pass a sponsored context", async () => {
+ wrapper = mount(
+ <DSContextFooter
+ context_type={bookmarkBadge}
+ context={context}
+ engagement={engagement}
+ />
+ );
+
+ assert.lengthOf(wrapper.find(".story-view-count"), 0);
+ assert.lengthOf(wrapper.find(StatusMessage), 0);
+ assert.equal(wrapper.find(".story-sponsored-label").text(), context);
+ });
+ it("should render a sponsored_by_override if passed a sponsored_by_override", async () => {
+ wrapper = mount(
+ <DSContextFooter
+ context_type={bookmarkBadge}
+ context={context}
+ sponsored_by_override={sponsored_by_override}
+ engagement={engagement}
+ />
+ );
+
+ assert.equal(
+ wrapper.find(".story-sponsored-label").text(),
+ sponsored_by_override
+ );
+ });
+ it("should render nothing with a sponsored_by_override empty string", async () => {
+ wrapper = mount(
+ <DSContextFooter
+ context_type={bookmarkBadge}
+ context={context}
+ sponsored_by_override=""
+ engagement={engagement}
+ />
+ );
+
+ assert.isFalse(wrapper.find(".story-sponsored-label").exists());
+ });
+ it("should render localized string with sponsor with no sponsored_by_override", async () => {
+ wrapper = mount(
+ <DSContextFooter
+ context_type={bookmarkBadge}
+ context={context}
+ sponsor="Nimoy"
+ engagement={engagement}
+ />
+ );
+
+ assert.equal(
+ wrapper.find(".story-sponsored-label").children().at(0).type(),
+ FluentOrText
+ );
+ });
+ it("should render a new badge if props change from an old badge to a new one", async () => {
+ wrapper = mount(<DSContextFooter context_type={bookmarkBadge} />);
+
+ const { fluentID: bookmarkFluentID } = cardContextTypes[bookmarkBadge];
+ const bookmarkStatusMessage = wrapper.find(
+ `div[data-l10n-id='${bookmarkFluentID}']`
+ );
+ assert.isTrue(bookmarkStatusMessage.exists());
+
+ const { fluentID: removeBookmarkFluentID } =
+ cardContextTypes[removeBookmarkBadge];
+
+ wrapper.setProps({ context_type: removeBookmarkBadge });
+ await wrapper.update();
+
+ assert.isEmpty(bookmarkStatusMessage);
+ const removedBookmarkStatusMessage = wrapper.find(
+ `div[data-l10n-id='${removeBookmarkFluentID}']`
+ );
+ assert.isTrue(removedBookmarkStatusMessage.exists());
+ });
+ it("should render a story footer", () => {
+ wrapper = mount(
+ <DSMessageFooter
+ context_type={bookmarkBadge}
+ engagement={engagement}
+ display_engagement_labels={true}
+ />
+ );
+
+ assert.lengthOf(wrapper.find(".story-footer"), 1);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx
new file mode 100644
index 0000000000..2f7e206b4f
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx
@@ -0,0 +1,51 @@
+import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<DSDismiss>", () => {
+ const fakeSpoc = {
+ url: "https://foo.com",
+ guid: "1234",
+ };
+ let wrapper;
+ let sandbox;
+ let onDismissClickStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ onDismissClickStub = sandbox.stub();
+ wrapper = shallow(
+ <DSDismiss
+ data={fakeSpoc}
+ onDismissClick={onDismissClickStub}
+ shouldSendImpressionStats={true}
+ />
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".ds-dismiss").exists());
+ });
+
+ it("should render proper hover state", () => {
+ wrapper.instance().onHover();
+ assert.ok(wrapper.find(".hovering").exists());
+ wrapper.instance().offHover();
+ assert.ok(!wrapper.find(".hovering").exists());
+ });
+
+ it("should dispatch call onDismissClick", () => {
+ wrapper.instance().onDismissClick();
+ assert.calledOnce(onDismissClickStub);
+ });
+
+ it("should add extra classes", () => {
+ wrapper = shallow(<DSDismiss extraClasses="extra-class" />);
+ assert.ok(wrapper.find(".extra-class").exists());
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx
new file mode 100644
index 0000000000..6aa8045299
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx
@@ -0,0 +1,73 @@
+import { DSEmptyState } from "content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<DSEmptyState>", () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow(<DSEmptyState />);
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".section-empty-state").exists());
+ });
+
+ it("should render defaultempty state message", () => {
+ assert.ok(wrapper.find(".empty-state-message").exists());
+ const header = wrapper.find(
+ "h2[data-l10n-id='newtab-discovery-empty-section-topstories-header']"
+ );
+ const paragraph = wrapper.find(
+ "p[data-l10n-id='newtab-discovery-empty-section-topstories-content']"
+ );
+
+ assert.ok(header.exists());
+ assert.ok(paragraph.exists());
+ });
+
+ it("should render failed state message", () => {
+ wrapper = shallow(<DSEmptyState status="failed" />);
+ const button = wrapper.find(
+ "button[data-l10n-id='newtab-discovery-empty-section-topstories-try-again-button']"
+ );
+
+ assert.ok(button.exists());
+ });
+
+ it("should render waiting state message", () => {
+ wrapper = shallow(<DSEmptyState status="waiting" />);
+ const button = wrapper.find(
+ "button[data-l10n-id='newtab-discovery-empty-section-topstories-loading']"
+ );
+
+ assert.ok(button.exists());
+ });
+
+ it("should dispatch DISCOVERY_STREAM_RETRY_FEED on failed state button click", () => {
+ const dispatch = sinon.spy();
+
+ wrapper = shallow(
+ <DSEmptyState
+ status="failed"
+ dispatch={dispatch}
+ feed={{ url: "https://foo.com", data: {} }}
+ />
+ );
+ wrapper.find("button.try-again-button").simulate("click");
+
+ assert.calledTwice(dispatch);
+ let [action] = dispatch.firstCall.args;
+ assert.equal(action.type, "DISCOVERY_STREAM_FEED_UPDATE");
+ assert.deepEqual(action.data.feed, {
+ url: "https://foo.com",
+ data: { status: "waiting" },
+ });
+
+ [action] = dispatch.secondCall.args;
+
+ assert.equal(action.type, "DISCOVERY_STREAM_RETRY_FEED");
+ assert.deepEqual(action.data.feed, { url: "https://foo.com", data: {} });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx
new file mode 100644
index 0000000000..bb2ce3b0b3
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx
@@ -0,0 +1,146 @@
+import { DSImage } from "content-src/components/DiscoveryStreamComponents/DSImage/DSImage";
+import { mount } from "enzyme";
+import React from "react";
+
+describe("Discovery Stream <DSImage>", () => {
+ it("should have a child with class ds-image", () => {
+ const img = mount(<DSImage />);
+ const child = img.find(".ds-image");
+
+ assert.lengthOf(child, 1);
+ });
+
+ it("should set proper sources if only `source` is available", () => {
+ const img = mount(<DSImage source="https://placekitten.com/g/640/480" />);
+
+ assert.equal(
+ img.find("img").prop("src"),
+ "https://placekitten.com/g/640/480"
+ );
+ });
+
+ it("should set proper sources if `rawSource` is available", () => {
+ const testSizes = [
+ {
+ mediaMatcher: "(min-width: 1122px)",
+ width: 296,
+ height: 148,
+ },
+
+ {
+ mediaMatcher: "(min-width: 866px)",
+ width: 218,
+ height: 109,
+ },
+
+ {
+ mediaMatcher: "(max-width: 610px)",
+ width: 202,
+ height: 101,
+ },
+ ];
+
+ const img = mount(
+ <DSImage
+ rawSource="https://placekitten.com/g/640/480"
+ sizes={testSizes}
+ />
+ );
+
+ assert.equal(
+ img.find("img").prop("src"),
+ "https://placekitten.com/g/640/480"
+ );
+ assert.equal(
+ img.find("img").prop("srcSet"),
+ [
+ "https://img-getpocket.cdn.mozilla.net/296x148/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 296w",
+ "https://img-getpocket.cdn.mozilla.net/592x296/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 592w",
+ "https://img-getpocket.cdn.mozilla.net/218x109/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 218w",
+ "https://img-getpocket.cdn.mozilla.net/436x218/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 436w",
+ "https://img-getpocket.cdn.mozilla.net/202x101/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 202w",
+ "https://img-getpocket.cdn.mozilla.net/404x202/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 404w",
+ ].join(",")
+ );
+ });
+
+ it("should fall back to unoptimized when optimized failed", () => {
+ const img = mount(
+ <DSImage
+ source="https://placekitten.com/g/640/480"
+ rawSource="https://placekitten.com/g/640/480"
+ />
+ );
+ img.setState({
+ isSeen: true,
+ containerWidth: 640,
+ containerHeight: 480,
+ });
+
+ img.instance().onOptimizedImageError();
+ img.update();
+
+ assert.equal(
+ img.find("img").prop("src"),
+ "https://placekitten.com/g/640/480"
+ );
+ });
+
+ it("should render a placeholder image with no source and recent save", () => {
+ const img = mount(<DSImage isRecentSave={true} url="foo" title="bar" />);
+ img.setState({ isSeen: true });
+
+ img.update();
+
+ assert.equal(img.find("div").prop("className"), "placeholder-image");
+ });
+
+ it("should render a broken image with a source and a recent save", () => {
+ const img = mount(<DSImage isRecentSave={true} source="foo" />);
+ img.setState({ isSeen: true });
+
+ img.instance().onNonOptimizedImageError();
+ img.update();
+
+ assert.equal(img.find("div").prop("className"), "broken-image");
+ });
+
+ it("should render a broken image without a source and not a recent save", () => {
+ const img = mount(<DSImage isRecentSave={false} />);
+ img.setState({ isSeen: true });
+
+ img.instance().onNonOptimizedImageError();
+ img.update();
+
+ assert.equal(img.find("div").prop("className"), "broken-image");
+ });
+
+ it("should update loaded state when seen", () => {
+ const img = mount(
+ <DSImage rawSource="https://placekitten.com/g/640/480" />
+ );
+
+ img.instance().onLoad();
+ assert.propertyVal(img.state(), "isLoaded", true);
+ });
+
+ describe("DSImage with Idle Callback", () => {
+ let wrapper;
+ let windowStub = {
+ requestIdleCallback: sinon.stub().returns(1),
+ cancelIdleCallback: sinon.stub(),
+ };
+ beforeEach(() => {
+ wrapper = mount(<DSImage windowObj={windowStub} />);
+ });
+
+ it("should call requestIdleCallback on componentDidMount", () => {
+ assert.calledOnce(windowStub.requestIdleCallback);
+ });
+
+ it("should call cancelIdleCallback on componentWillUnmount", () => {
+ wrapper.instance().componentWillUnmount();
+ assert.calledOnce(windowStub.cancelIdleCallback);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx
new file mode 100644
index 0000000000..3aa128a32a
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx
@@ -0,0 +1,151 @@
+import { mount, shallow } from "enzyme";
+import { DSLinkMenu } from "content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import React from "react";
+
+describe("<DSLinkMenu>", () => {
+ let wrapper;
+
+ describe("DS link menu actions", () => {
+ beforeEach(() => {
+ wrapper = mount(<DSLinkMenu />);
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ });
+
+ it("should parse args for fluent correctly ", () => {
+ const title = '"fluent"';
+ wrapper = mount(<DSLinkMenu title={title} />);
+
+ const button = wrapper.find(
+ "button[data-l10n-id='newtab-menu-content-tooltip']"
+ );
+ assert.equal(button.prop("data-l10n-args"), JSON.stringify({ title }));
+ });
+ });
+
+ describe("DS context menu options", () => {
+ const ValidDSLinkMenuProps = {
+ site: {},
+ pocket_button_enabled: true,
+ };
+
+ beforeEach(() => {
+ wrapper = shallow(<DSLinkMenu {...ValidDSLinkMenuProps} />);
+ });
+
+ it("should render a context menu button", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(
+ wrapper.find(ContextMenuButton).exists(),
+ "context menu button exists"
+ );
+ });
+
+ it("should render LinkMenu when context menu button is clicked", () => {
+ let button = wrapper.find(ContextMenuButton);
+ button.simulate("click", { preventDefault: () => {} });
+ assert.equal(wrapper.find(LinkMenu).length, 1);
+ });
+
+ it("should pass dispatch, onShow, site, options, shouldSendImpressionStats, source and index to LinkMenu", () => {
+ wrapper
+ .find(ContextMenuButton)
+ .simulate("click", { preventDefault: () => {} });
+ const linkMenuProps = wrapper.find(LinkMenu).props();
+ [
+ "dispatch",
+ "onShow",
+ "site",
+ "index",
+ "options",
+ "source",
+ "shouldSendImpressionStats",
+ ].forEach(prop => assert.property(linkMenuProps, prop));
+ });
+
+ it("should pass through the correct menu options to LinkMenu", () => {
+ wrapper
+ .find(ContextMenuButton)
+ .simulate("click", { preventDefault: () => {} });
+ const linkMenuProps = wrapper.find(LinkMenu).props();
+ assert.deepEqual(linkMenuProps.options, [
+ "CheckBookmark",
+ "CheckArchiveFromPocket",
+ "CheckSavedToPocket",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ ]);
+ });
+
+ it("should pass through the correct menu options to LinkMenu for spocs", () => {
+ wrapper = shallow(
+ <DSLinkMenu
+ {...ValidDSLinkMenuProps}
+ flightId="1234"
+ showPrivacyInfo={true}
+ />
+ );
+ wrapper
+ .find(ContextMenuButton)
+ .simulate("click", { preventDefault: () => {} });
+ const linkMenuProps = wrapper.find(LinkMenu).props();
+ assert.deepEqual(linkMenuProps.options, [
+ "CheckBookmark",
+ "CheckArchiveFromPocket",
+ "CheckSavedToPocket",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ "ShowPrivacyInfo",
+ ]);
+ });
+
+ it("should pass through the correct menu options to LinkMenu for save to Pocket button", () => {
+ wrapper = shallow(
+ <DSLinkMenu {...ValidDSLinkMenuProps} saveToPocketCard={true} />
+ );
+ wrapper
+ .find(ContextMenuButton)
+ .simulate("click", { preventDefault: () => {} });
+ const linkMenuProps = wrapper.find(LinkMenu).props();
+ assert.deepEqual(linkMenuProps.options, [
+ "CheckBookmark",
+ "CheckArchiveFromPocket",
+ "CheckDeleteFromPocket",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ ]);
+ });
+
+ it("should pass through the correct menu options to LinkMenu if Pocket is disabled", () => {
+ wrapper = shallow(
+ <DSLinkMenu {...ValidDSLinkMenuProps} pocket_button_enabled={false} />
+ );
+ wrapper
+ .find(ContextMenuButton)
+ .simulate("click", { preventDefault: () => {} });
+ const linkMenuProps = wrapper.find(LinkMenu).props();
+ assert.deepEqual(linkMenuProps.options, [
+ "CheckBookmark",
+ "CheckArchiveFromPocket",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ ]);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx
new file mode 100644
index 0000000000..7d9f13cc8a
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx
@@ -0,0 +1,57 @@
+import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage";
+import React from "react";
+import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+import { mount } from "enzyme";
+
+describe("<DSMessage>", () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(<DSMessage />);
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".ds-message").exists());
+ });
+
+ it("should render an icon", () => {
+ wrapper.setProps({ icon: "foo" });
+
+ assert.ok(wrapper.find(".glyph").exists());
+ assert.propertyVal(
+ wrapper.find(".glyph").props().style,
+ "backgroundImage",
+ `url(foo)`
+ );
+ });
+
+ it("should render a title", () => {
+ wrapper.setProps({ title: "foo" });
+
+ assert.ok(wrapper.find(".title-text").exists());
+ assert.equal(wrapper.find(".title-text").text(), "foo");
+ });
+
+ it("should render a SafeAnchor", () => {
+ wrapper.setProps({ link_text: "foo", link_url: "https://foo.com" });
+
+ assert.equal(wrapper.find(".title").children().at(0).type(), SafeAnchor);
+ });
+
+ it("should render a FluentOrText", () => {
+ wrapper.setProps({
+ link_text: "link_text",
+ title: "title",
+ link_url: "https://link_url.com",
+ });
+
+ assert.equal(
+ wrapper.find(".title-text").children().at(0).type(),
+ FluentOrText
+ );
+
+ assert.equal(wrapper.find(".link a").children().at(0).type(), FluentOrText);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx
new file mode 100644
index 0000000000..b4b743c7ff
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx
@@ -0,0 +1,50 @@
+import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal";
+import { shallow, mount } from "enzyme";
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import React from "react";
+
+describe("Discovery Stream <DSPrivacyModal>", () => {
+ let sandbox;
+ let dispatch;
+ let wrapper;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ dispatch = sandbox.stub();
+ wrapper = shallow(<DSPrivacyModal dispatch={dispatch} />);
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should contain a privacy notice", () => {
+ const modal = mount(<DSPrivacyModal />);
+ const child = modal.find(".privacy-notice");
+
+ assert.lengthOf(child, 1);
+ });
+
+ it("should call dispatch when modal is closed", () => {
+ wrapper.instance().closeModal();
+ assert.calledOnce(dispatch);
+ });
+
+ it("should call dispatch with the correct events for onLearnLinkClick", () => {
+ wrapper.instance().onLearnLinkClick();
+
+ assert.calledOnce(dispatch);
+ assert.calledWith(
+ dispatch,
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK_PRIVACY_INFO",
+ source: "DS_PRIVACY_MODAL",
+ })
+ );
+ });
+
+ it("should call dispatch with the correct events for onManageLinkClick", () => {
+ wrapper.instance().onManageLinkClick();
+
+ assert.calledOnce(dispatch);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSSignup.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSSignup.test.jsx
new file mode 100644
index 0000000000..904f98e439
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSSignup.test.jsx
@@ -0,0 +1,92 @@
+import { DSSignup } from "content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<DSSignup>", () => {
+ let wrapper;
+ let sandbox;
+ let dispatchStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ dispatchStub = sandbox.stub();
+ wrapper = shallow(
+ <DSSignup
+ data={{
+ spocs: [
+ {
+ shim: { impression: "1234" },
+ id: "1234",
+ },
+ ],
+ }}
+ type="SIGNUP"
+ dispatch={dispatchStub}
+ />
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".ds-signup").exists());
+ });
+
+ it("should dispatch a click event on click", () => {
+ wrapper.instance().onLinkClick();
+
+ assert.calledTwice(dispatchStub);
+ assert.deepEqual(dispatchStub.firstCall.args[0].data, {
+ event: "CLICK",
+ source: "SIGNUP",
+ action_position: 0,
+ });
+ assert.deepEqual(dispatchStub.secondCall.args[0].data, {
+ source: "SIGNUP",
+ click: 0,
+ tiles: [{ id: "1234", pos: 0 }],
+ });
+ });
+
+ it("Should remove active on Menu Update", () => {
+ wrapper.setState = sandbox.stub();
+ wrapper.instance().onMenuButtonUpdate(false);
+ assert.calledWith(wrapper.setState, { active: false, lastItem: false });
+ });
+
+ it("Should add active on Menu Show", async () => {
+ wrapper.setState = sandbox.stub();
+ wrapper.instance().nextAnimationFrame = () => {};
+ await wrapper.instance().onMenuShow();
+ assert.calledWith(wrapper.setState, { active: true, lastItem: false });
+ });
+
+ it("Should add last-item to support resized window", async () => {
+ const fakeWindow = { scrollMaxX: "20" };
+ wrapper = shallow(<DSSignup windowObj={fakeWindow} />);
+ wrapper.setState = sandbox.stub();
+ wrapper.instance().nextAnimationFrame = () => {};
+ await wrapper.instance().onMenuShow();
+ assert.calledWith(wrapper.setState, { active: true, lastItem: true });
+ });
+
+ it("Should add last-item and active classes", () => {
+ wrapper.setState({
+ active: true,
+ lastItem: true,
+ });
+ assert.ok(wrapper.find(".last-item").exists());
+ assert.ok(wrapper.find(".active").exists());
+ });
+
+ it("Should call rAF from nextAnimationFrame", () => {
+ const fakeWindow = { requestAnimationFrame: sinon.stub() };
+ wrapper = shallow(<DSSignup windowObj={fakeWindow} />);
+
+ wrapper.instance().nextAnimationFrame();
+ assert.calledOnce(fakeWindow.requestAnimationFrame);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx
new file mode 100644
index 0000000000..1888e194af
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx
@@ -0,0 +1,94 @@
+import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<DSTextPromo>", () => {
+ let wrapper;
+ let sandbox;
+ let dispatchStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ dispatchStub = sandbox.stub();
+ wrapper = shallow(
+ <DSTextPromo
+ data={{
+ spocs: [
+ {
+ shim: { impression: "1234" },
+ id: "1234",
+ },
+ ],
+ }}
+ type="TEXTPROMO"
+ dispatch={dispatchStub}
+ />
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".ds-text-promo").exists());
+ });
+
+ it("should render a header", () => {
+ wrapper.setProps({ header: "foo" });
+ assert.ok(wrapper.find(".text").exists());
+ });
+
+ it("should render a subtitle", () => {
+ wrapper.setProps({ subtitle: "foo" });
+ assert.ok(wrapper.find(".subtitle").exists());
+ });
+
+ it("should dispatch a click event on click", () => {
+ wrapper.instance().onLinkClick();
+
+ assert.calledTwice(dispatchStub);
+ assert.deepEqual(dispatchStub.firstCall.args[0].data, {
+ event: "CLICK",
+ source: "TEXTPROMO",
+ action_position: 0,
+ });
+ assert.deepEqual(dispatchStub.secondCall.args[0].data, {
+ source: "TEXTPROMO",
+ click: 0,
+ tiles: [{ id: "1234", pos: 0 }],
+ });
+ });
+
+ it("should dispath telemety events on dismiss", () => {
+ wrapper.instance().onDismissClick();
+
+ const firstCall = dispatchStub.getCall(0);
+ const secondCall = dispatchStub.getCall(1);
+ const thirdCall = dispatchStub.getCall(2);
+
+ assert.equal(firstCall.args[0].type, "BLOCK_URL");
+ assert.deepEqual(firstCall.args[0].data, [
+ {
+ url: undefined,
+ pocket_id: undefined,
+ isSponsoredTopSite: undefined,
+ },
+ ]);
+
+ assert.equal(secondCall.args[0].type, "DISCOVERY_STREAM_USER_EVENT");
+ assert.deepEqual(secondCall.args[0].data, {
+ event: "BLOCK",
+ source: "TEXTPROMO",
+ action_position: 0,
+ });
+
+ assert.equal(thirdCall.args[0].type, "TELEMETRY_IMPRESSION_STATS");
+ assert.deepEqual(thirdCall.args[0].data, {
+ source: "TEXTPROMO",
+ block: 0,
+ tiles: [{ id: "1234", pos: 0 }],
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx
new file mode 100644
index 0000000000..d8c16d8e71
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx
@@ -0,0 +1,41 @@
+import { combineReducers, createStore } from "redux";
+import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
+import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights";
+import { mount } from "enzyme";
+import { Provider } from "react-redux";
+import React from "react";
+
+describe("Discovery Stream <Highlights>", () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.unmount();
+ });
+
+ it("should render nothing with no highlights data", () => {
+ const store = createStore(combineReducers(reducers), { ...INITIAL_STATE });
+
+ wrapper = mount(
+ <Provider store={store}>
+ <Highlights />
+ </Provider>
+ );
+
+ assert.ok(wrapper.isEmptyRender());
+ });
+
+ it("should render highlights", () => {
+ const store = createStore(combineReducers(reducers), {
+ ...INITIAL_STATE,
+ Sections: [{ id: "highlights", enabled: true }],
+ });
+
+ wrapper = mount(
+ <Provider store={store}>
+ <Highlights />
+ </Provider>
+ );
+
+ assert.lengthOf(wrapper.find(".ds-highlights"), 1);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx
new file mode 100644
index 0000000000..03538df6f2
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx
@@ -0,0 +1,16 @@
+import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<HorizontalRule>", () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow(<HorizontalRule />);
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".ds-hr").exists());
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx
new file mode 100644
index 0000000000..1d4778e342
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx
@@ -0,0 +1,278 @@
+"use strict";
+
+import {
+ ImpressionStats,
+ INTERSECTION_RATIO,
+} from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<ImpressionStats>", () => {
+ const SOURCE = "TEST_SOURCE";
+ const FullIntersectEntries = [
+ { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO },
+ ];
+ const ZeroIntersectEntries = [
+ { isIntersecting: false, intersectionRatio: 0 },
+ ];
+ const PartialIntersectEntries = [
+ { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO / 2 },
+ ];
+
+ // Build IntersectionObserver class with the arg `entries` for the intersect callback.
+ function buildIntersectionObserver(entries) {
+ return class {
+ constructor(callback) {
+ this.callback = callback;
+ }
+
+ observe() {
+ this.callback(entries);
+ }
+
+ unobserve() {}
+ };
+ }
+
+ const DEFAULT_PROPS = {
+ rows: [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ { id: 3, pos: 2 },
+ ],
+ source: SOURCE,
+ IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
+ document: {
+ visibilityState: "visible",
+ addEventListener: sinon.stub(),
+ removeEventListener: sinon.stub(),
+ },
+ };
+
+ const InnerEl = () => <div>Inner Element</div>;
+
+ function renderImpressionStats(props = {}) {
+ return shallow(
+ <ImpressionStats {...DEFAULT_PROPS} {...props}>
+ <InnerEl />
+ </ImpressionStats>
+ );
+ }
+
+ it("should render props.children", () => {
+ const wrapper = renderImpressionStats();
+ assert.ok(wrapper.contains(<InnerEl />));
+ });
+ it("should not send loaded content nor impression when the page is not visible", () => {
+ const dispatch = sinon.spy();
+ const props = {
+ dispatch,
+ document: {
+ visibilityState: "hidden",
+ addEventListener: sinon.spy(),
+ removeEventListener: sinon.spy(),
+ },
+ };
+ renderImpressionStats(props);
+
+ assert.notCalled(dispatch);
+ });
+ it("should noly send loaded content but not impression when the wrapped item is not visbible", () => {
+ const dispatch = sinon.spy();
+ const props = {
+ dispatch,
+ IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries),
+ };
+ renderImpressionStats(props);
+
+ // This one is for loaded content.
+ assert.calledOnce(dispatch);
+ const [action] = dispatch.firstCall.args;
+ assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
+ assert.equal(action.data.source, SOURCE);
+ assert.deepEqual(action.data.tiles, [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ { id: 3, pos: 2 },
+ ]);
+ });
+ it("should not send impression when the wrapped item is visbible but below the ratio", () => {
+ const dispatch = sinon.spy();
+ const props = {
+ dispatch,
+ IntersectionObserver: buildIntersectionObserver(PartialIntersectEntries),
+ };
+ renderImpressionStats(props);
+
+ // This one is for loaded content.
+ assert.calledOnce(dispatch);
+ });
+ it("should send a loaded content and an impression when the page is visible and the wrapped item meets the visibility ratio", () => {
+ const dispatch = sinon.spy();
+ const props = {
+ dispatch,
+ IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
+ };
+ renderImpressionStats(props);
+
+ assert.calledTwice(dispatch);
+
+ let [action] = dispatch.firstCall.args;
+ assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
+ assert.equal(action.data.source, SOURCE);
+ assert.deepEqual(action.data.tiles, [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ { id: 3, pos: 2 },
+ ]);
+
+ [action] = dispatch.secondCall.args;
+ assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS);
+ assert.equal(action.data.source, SOURCE);
+ assert.deepEqual(action.data.tiles, [
+ { id: 1, pos: 0, type: "organic" },
+ { id: 2, pos: 1, type: "organic" },
+ { id: 3, pos: 2, type: "organic" },
+ ]);
+ });
+ it("should send a DISCOVERY_STREAM_SPOC_IMPRESSION when the wrapped item has a flightId", () => {
+ const dispatch = sinon.spy();
+ const flightId = "a_flight_id";
+ const props = {
+ dispatch,
+ flightId,
+ rows: [{ id: 1, pos: 1, advertiser: "test advertiser" }],
+ source: "TOP_SITES",
+ IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
+ };
+ renderImpressionStats(props);
+
+ // Loaded content + DISCOVERY_STREAM_SPOC_IMPRESSION + TOP_SITES_SPONSORED_IMPRESSION_STATS + impression
+ assert.callCount(dispatch, 4);
+
+ const [action] = dispatch.secondCall.args;
+ assert.equal(action.type, at.DISCOVERY_STREAM_SPOC_IMPRESSION);
+ assert.deepEqual(action.data, { flightId });
+ });
+ it("should send a TOP_SITES_SPONSORED_IMPRESSION_STATS when the wrapped item has a flightId", () => {
+ const dispatch = sinon.spy();
+ const flightId = "a_flight_id";
+ const props = {
+ dispatch,
+ flightId,
+ rows: [{ id: 1, pos: 1, advertiser: "test advertiser" }],
+ source: "TOP_SITES",
+ IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
+ };
+ renderImpressionStats(props);
+
+ // Loaded content + DISCOVERY_STREAM_SPOC_IMPRESSION + TOP_SITES_SPONSORED_IMPRESSION_STATS + impression
+ assert.callCount(dispatch, 4);
+
+ const [action] = dispatch.getCall(2).args;
+ assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS);
+ assert.deepEqual(action.data, {
+ type: "impression",
+ tile_id: 1,
+ source: "newtab",
+ advertiser: "test advertiser",
+ position: 1,
+ });
+ });
+ it("should send an impression when the wrapped item transiting from invisible to visible", () => {
+ const dispatch = sinon.spy();
+ const props = {
+ dispatch,
+ IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries),
+ };
+ const wrapper = renderImpressionStats(props);
+
+ // For the loaded content
+ assert.calledOnce(dispatch);
+
+ let [action] = dispatch.firstCall.args;
+ assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
+ assert.equal(action.data.source, SOURCE);
+ assert.deepEqual(action.data.tiles, [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ { id: 3, pos: 2 },
+ ]);
+
+ dispatch.resetHistory();
+ wrapper.instance().impressionObserver.callback(FullIntersectEntries);
+
+ // For the impression
+ assert.calledOnce(dispatch);
+
+ [action] = dispatch.firstCall.args;
+ assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS);
+ assert.deepEqual(action.data.tiles, [
+ { id: 1, pos: 0, type: "organic" },
+ { id: 2, pos: 1, type: "organic" },
+ { id: 3, pos: 2, type: "organic" },
+ ]);
+ });
+ it("should remove visibility change listener when the wrapper is removed", () => {
+ const props = {
+ dispatch: sinon.spy(),
+ document: {
+ visibilityState: "hidden",
+ addEventListener: sinon.spy(),
+ removeEventListener: sinon.spy(),
+ },
+ IntersectionObserver,
+ };
+
+ const wrapper = renderImpressionStats(props);
+ assert.calledWith(props.document.addEventListener, "visibilitychange");
+ const [, listener] = props.document.addEventListener.firstCall.args;
+
+ wrapper.unmount();
+ assert.calledWith(
+ props.document.removeEventListener,
+ "visibilitychange",
+ listener
+ );
+ });
+ it("should unobserve the intersection observer when the wrapper is removed", () => {
+ const IntersectionObserver =
+ buildIntersectionObserver(ZeroIntersectEntries);
+ const spy = sinon.spy(IntersectionObserver.prototype, "unobserve");
+ const props = { dispatch: sinon.spy(), IntersectionObserver };
+
+ const wrapper = renderImpressionStats(props);
+ wrapper.unmount();
+
+ assert.calledOnce(spy);
+ });
+ it("should only send the latest impression on a visibility change", () => {
+ const listeners = new Set();
+ const props = {
+ dispatch: sinon.spy(),
+ document: {
+ visibilityState: "hidden",
+ addEventListener: (ev, cb) => listeners.add(cb),
+ removeEventListener: (ev, cb) => listeners.delete(cb),
+ },
+ };
+
+ const wrapper = renderImpressionStats(props);
+
+ // Update twice
+ wrapper.setProps({ ...props, ...{ rows: [{ id: 123, pos: 4 }] } });
+ wrapper.setProps({ ...props, ...{ rows: [{ id: 2432, pos: 5 }] } });
+
+ assert.notCalled(props.dispatch);
+
+ // Simulate listeners getting called
+ props.document.visibilityState = "visible";
+ listeners.forEach(l => l());
+
+ // Make sure we only sent the latest event
+ assert.calledTwice(props.dispatch);
+ const [action] = props.dispatch.firstCall.args;
+ assert.deepEqual(action.data.tiles, [{ id: 2432, pos: 5 }]);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx
new file mode 100644
index 0000000000..ef5baf50c1
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx
@@ -0,0 +1,131 @@
+import {
+ Navigation,
+ Topic,
+} from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation";
+import React from "react";
+import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+import { shallow, mount } from "enzyme";
+
+const DEFAULT_PROPS = {
+ App: {
+ isForStartupCache: false,
+ },
+};
+
+describe("<Navigation>", () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(<Navigation header={{}} locale="en-US" />);
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ });
+
+ it("should render a title", () => {
+ wrapper.setProps({ header: { title: "Foo" } });
+
+ assert.equal(wrapper.find(".ds-navigation-header").text(), "Foo");
+ });
+
+ it("should not render a title", () => {
+ wrapper.setProps({ header: null });
+
+ assert.lengthOf(wrapper.find(".ds-navigation-header"), 0);
+ });
+
+ it("should set default alignment", () => {
+ assert.lengthOf(wrapper.find(".ds-navigation-centered"), 1);
+ });
+
+ it("should set custom alignment", () => {
+ wrapper.setProps({ alignment: "left-align" });
+
+ assert.lengthOf(wrapper.find(".ds-navigation-left-align"), 1);
+ });
+
+ it("should set default of no links", () => {
+ assert.lengthOf(wrapper.find("ul").children(), 0);
+ });
+
+ it("should render a FluentOrText", () => {
+ wrapper.setProps({ header: { title: "Foo" } });
+
+ assert.equal(
+ wrapper.find(".ds-navigation").children().at(0).type(),
+ FluentOrText
+ );
+ });
+
+ it("should render 2 Topics", () => {
+ wrapper.setProps({
+ links: [
+ { url: "https://foo.com", name: "foo" },
+ { url: "https://bar.com", name: "bar" },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find("ul").children(), 2);
+ });
+
+ it("should render 2 extra Topics", () => {
+ wrapper.setProps({
+ newFooterSection: true,
+ links: [
+ { url: "https://foo.com", name: "foo" },
+ { url: "https://bar.com", name: "bar" },
+ ],
+ extraLinks: [
+ { url: "https://foo.com", name: "foo" },
+ { url: "https://bar.com", name: "bar" },
+ ],
+ });
+
+ assert.lengthOf(wrapper.find("ul").children(), 4);
+ });
+});
+
+describe("<Topic>", () => {
+ let wrapper;
+ let sandbox;
+
+ beforeEach(() => {
+ wrapper = shallow(<Topic url="https://foo.com" name="foo" />);
+ sandbox = sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should pass onLinkClick prop", () => {
+ assert.propertyVal(
+ wrapper.at(0).props(),
+ "onLinkClick",
+ wrapper.instance().onLinkClick
+ );
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.type(), SafeAnchor);
+ });
+
+ describe("onLinkClick", () => {
+ let dispatch;
+
+ beforeEach(() => {
+ dispatch = sandbox.stub();
+ wrapper = shallow(<Topic dispatch={dispatch} {...DEFAULT_PROPS} />);
+ wrapper.setState({ isSeen: true });
+ });
+
+ it("should call dispatch", () => {
+ wrapper.instance().onLinkClick({ target: { text: `Must Reads` } });
+
+ assert.calledOnce(dispatch);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/PrivacyLink.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/PrivacyLink.test.jsx
new file mode 100644
index 0000000000..285cc16c0e
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/PrivacyLink.test.jsx
@@ -0,0 +1,29 @@
+import { PrivacyLink } from "content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<PrivacyLink>", () => {
+ let wrapper;
+ let sandbox;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ wrapper = shallow(
+ <PrivacyLink
+ properties={{
+ url: "url",
+ title: "Privacy Link",
+ }}
+ />
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".ds-privacy-link").exists());
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx
new file mode 100644
index 0000000000..5d643869b8
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor";
+import { shallow } from "enzyme";
+
+describe("Discovery Stream <SafeAnchor>", () => {
+ let warnStub;
+ let sandbox;
+ beforeEach(() => {
+ warnStub = sinon.stub(console, "warn");
+ sandbox = sinon.createSandbox();
+ });
+ afterEach(() => {
+ warnStub.restore();
+ sandbox.restore();
+ });
+ it("should render with anchor", () => {
+ const wrapper = shallow(<SafeAnchor />);
+ assert.lengthOf(wrapper.find("a"), 1);
+ });
+ it("should render with anchor target for http", () => {
+ const wrapper = shallow(<SafeAnchor url="http://example.com" />);
+ assert.equal(wrapper.find("a").prop("href"), "http://example.com");
+ });
+ it("should render with anchor target for https", () => {
+ const wrapper = shallow(<SafeAnchor url="https://example.com" />);
+ assert.equal(wrapper.find("a").prop("href"), "https://example.com");
+ });
+ it("should not allow javascript: URIs", () => {
+ const wrapper = shallow(<SafeAnchor url="javascript:foo()" />); // eslint-disable-line no-script-url
+ assert.equal(wrapper.find("a").prop("href"), "");
+ assert.calledOnce(warnStub);
+ });
+ it("should not warn if the URL is falsey ", () => {
+ const wrapper = shallow(<SafeAnchor url="" />);
+ assert.equal(wrapper.find("a").prop("href"), "");
+ assert.notCalled(warnStub);
+ });
+ it("should dispatch an event on click", () => {
+ const dispatchStub = sandbox.stub();
+ const fakeEvent = { preventDefault: sandbox.stub(), currentTarget: {} };
+ const wrapper = shallow(<SafeAnchor dispatch={dispatchStub} />);
+
+ wrapper.find("a").simulate("click", fakeEvent);
+
+ assert.calledOnce(dispatchStub);
+ assert.calledOnce(fakeEvent.preventDefault);
+ });
+ it("should call onLinkClick if provided", () => {
+ const onLinkClickStub = sandbox.stub();
+ const wrapper = shallow(<SafeAnchor onLinkClick={onLinkClickStub} />);
+
+ wrapper.find("a").simulate("click");
+
+ assert.calledOnce(onLinkClickStub);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx
new file mode 100644
index 0000000000..b5ea007022
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx
@@ -0,0 +1,22 @@
+import React from "react";
+import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle";
+import { shallow } from "enzyme";
+
+describe("<SectionTitle>", () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow(<SectionTitle header={{}} />);
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".ds-section-title").exists());
+ });
+
+ it("should render a subtitle", () => {
+ wrapper.setProps({ header: { title: "Foo", subtitle: "Bar" } });
+
+ assert.equal(wrapper.find(".subtitle").text(), "Bar");
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx
new file mode 100644
index 0000000000..f879600a8f
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx
@@ -0,0 +1,238 @@
+import { combineReducers, createStore } from "redux";
+import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
+import { Provider } from "react-redux";
+import {
+ _TopicsWidget as TopicsWidgetBase,
+ TopicsWidget,
+} from "content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget";
+import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor";
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { mount } from "enzyme";
+import React from "react";
+
+describe("Discovery Stream <TopicsWidget>", () => {
+ let sandbox;
+ let wrapper;
+ let dispatch;
+ let fakeWindow;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ dispatch = sandbox.stub();
+ fakeWindow = {
+ innerWidth: 1000,
+ innerHeight: 900,
+ };
+
+ wrapper = mount(
+ <TopicsWidgetBase
+ dispatch={dispatch}
+ source="CARDGRID_WIDGET"
+ position={2}
+ id={1}
+ windowObj={fakeWindow}
+ DiscoveryStream={{
+ experimentData: {
+ utmCampaign: "utmCampaign",
+ utmContent: "utmContent",
+ utmSource: "utmSource",
+ },
+ }}
+ />
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".ds-topics-widget").exists());
+ });
+
+ it("should connect with DiscoveryStream store", () => {
+ let store = createStore(combineReducers(reducers), INITIAL_STATE);
+ wrapper = mount(
+ <Provider store={store}>
+ <TopicsWidget />
+ </Provider>
+ );
+
+ const topicsWidget = wrapper.find(TopicsWidgetBase);
+ assert.ok(topicsWidget.exists());
+ assert.lengthOf(topicsWidget, 1);
+ assert.deepEqual(
+ topicsWidget.props().DiscoveryStream.experimentData,
+ INITIAL_STATE.DiscoveryStream.experimentData
+ );
+ });
+
+ describe("dispatch", () => {
+ it("should dispatch loaded event", () => {
+ assert.callCount(dispatch, 1);
+ const [first] = dispatch.getCalls();
+ assert.calledWith(
+ first,
+ ac.DiscoveryStreamLoadedContent({
+ source: "CARDGRID_WIDGET",
+ tiles: [
+ {
+ id: 1,
+ pos: 2,
+ },
+ ],
+ })
+ );
+ });
+
+ it("should dispatch click event for technology", () => {
+ // Click technology topic.
+ wrapper.find(SafeAnchor).at(0).simulate("click");
+
+ // First call is DiscoveryStreamLoadedContent, which is already tested.
+ const [second, third, fourth] = dispatch.getCalls().slice(1, 4);
+
+ assert.callCount(dispatch, 4);
+ assert.calledWith(
+ second,
+ ac.OnlyToMain({
+ type: at.OPEN_LINK,
+ data: {
+ event: {
+ altKey: undefined,
+ button: undefined,
+ ctrlKey: undefined,
+ metaKey: undefined,
+ shiftKey: undefined,
+ },
+ referrer: "https://getpocket.com/recommendations",
+ url: "https://getpocket.com/explore/technology?utm_source=utmSource&utm_content=utmContent&utm_campaign=utmCampaign",
+ },
+ })
+ );
+ assert.calledWith(
+ third,
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "CARDGRID_WIDGET",
+ action_position: 2,
+ value: {
+ card_type: "topics_widget",
+ topic: "technology",
+ position_in_card: 0,
+ },
+ })
+ );
+ assert.calledWith(
+ fourth,
+ ac.ImpressionStats({
+ click: 0,
+ source: "CARDGRID_WIDGET",
+ tiles: [{ id: 1, pos: 2 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ })
+ );
+ });
+
+ it("should dispatch click event for must reads", () => {
+ // Click must reads topic.
+ wrapper.find(SafeAnchor).at(8).simulate("click");
+
+ // First call is DiscoveryStreamLoadedContent, which is already tested.
+ const [second, third, fourth] = dispatch.getCalls().slice(1, 4);
+
+ assert.callCount(dispatch, 4);
+ assert.calledWith(
+ second,
+ ac.OnlyToMain({
+ type: at.OPEN_LINK,
+ data: {
+ event: {
+ altKey: undefined,
+ button: undefined,
+ ctrlKey: undefined,
+ metaKey: undefined,
+ shiftKey: undefined,
+ },
+ referrer: "https://getpocket.com/recommendations",
+ url: "https://getpocket.com/collections?utm_source=utmSource&utm_content=utmContent&utm_campaign=utmCampaign",
+ },
+ })
+ );
+ assert.calledWith(
+ third,
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "CARDGRID_WIDGET",
+ action_position: 2,
+ value: {
+ card_type: "topics_widget",
+ topic: "must-reads",
+ position_in_card: 8,
+ },
+ })
+ );
+ assert.calledWith(
+ fourth,
+ ac.ImpressionStats({
+ click: 0,
+ source: "CARDGRID_WIDGET",
+ tiles: [{ id: 1, pos: 2 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ })
+ );
+ });
+
+ it("should dispatch click event for more topics", () => {
+ // Click more-topics.
+ wrapper.find(SafeAnchor).at(9).simulate("click");
+
+ // First call is DiscoveryStreamLoadedContent, which is already tested.
+ const [second, third, fourth] = dispatch.getCalls().slice(1, 4);
+
+ assert.callCount(dispatch, 4);
+ assert.calledWith(
+ second,
+ ac.OnlyToMain({
+ type: at.OPEN_LINK,
+ data: {
+ event: {
+ altKey: undefined,
+ button: undefined,
+ ctrlKey: undefined,
+ metaKey: undefined,
+ shiftKey: undefined,
+ },
+ referrer: "https://getpocket.com/recommendations",
+ url: "https://getpocket.com/?utm_source=utmSource&utm_content=utmContent&utm_campaign=utmCampaign",
+ },
+ })
+ );
+ assert.calledWith(
+ third,
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "CARDGRID_WIDGET",
+ action_position: 2,
+ value: { card_type: "topics_widget", topic: "more-topics" },
+ })
+ );
+ assert.calledWith(
+ fourth,
+ ac.ImpressionStats({
+ click: 0,
+ source: "CARDGRID_WIDGET",
+ tiles: [{ id: 1, pos: 2 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ })
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/ErrorBoundary.test.jsx b/browser/components/newtab/test/unit/content-src/components/ErrorBoundary.test.jsx
new file mode 100644
index 0000000000..99cc8b0ca7
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/ErrorBoundary.test.jsx
@@ -0,0 +1,110 @@
+import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton";
+import {
+ ErrorBoundary,
+ ErrorBoundaryFallback,
+} from "content-src/components/ErrorBoundary/ErrorBoundary";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<ErrorBoundary>", () => {
+ it("should render its children if componentDidCatch wasn't called", () => {
+ const wrapper = shallow(
+ <ErrorBoundary>
+ <div className="kids" />
+ </ErrorBoundary>
+ );
+
+ assert.lengthOf(wrapper.find(".kids"), 1);
+ });
+
+ it("should render ErrorBoundaryFallback if componentDidCatch called", () => {
+ const wrapper = shallow(<ErrorBoundary />);
+
+ wrapper.instance().componentDidCatch();
+ // since shallow wrappers don't automatically manage lifecycle semantics:
+ wrapper.update();
+
+ assert.lengthOf(wrapper.find(ErrorBoundaryFallback), 1);
+ });
+
+ it("should render the given FallbackComponent if componentDidCatch called", () => {
+ class TestFallback extends React.PureComponent {
+ render() {
+ return <div className="my-fallback">doh!</div>;
+ }
+ }
+
+ const wrapper = shallow(<ErrorBoundary FallbackComponent={TestFallback} />);
+ wrapper.instance().componentDidCatch();
+ // since shallow wrappers don't automatically manage lifecycle semantics:
+ wrapper.update();
+
+ assert.lengthOf(wrapper.find(TestFallback), 1);
+ });
+
+ it("should pass the given className prop to the FallbackComponent", () => {
+ class TestFallback extends React.PureComponent {
+ render() {
+ return <div className={this.props.className}>doh!</div>;
+ }
+ }
+
+ const wrapper = shallow(
+ <ErrorBoundary FallbackComponent={TestFallback} className="sheep" />
+ );
+ wrapper.instance().componentDidCatch();
+ // since shallow wrappers don't automatically manage lifecycle semantics:
+ wrapper.update();
+
+ assert.lengthOf(wrapper.find(".sheep"), 1);
+ });
+});
+
+describe("ErrorBoundaryFallback", () => {
+ it("should render a <div> with a class of as-error-fallback", () => {
+ const wrapper = shallow(<ErrorBoundaryFallback />);
+
+ assert.lengthOf(wrapper.find("div.as-error-fallback"), 1);
+ });
+
+ it("should render a <div> with the props.className and .as-error-fallback", () => {
+ const wrapper = shallow(<ErrorBoundaryFallback className="monkeys" />);
+
+ assert.lengthOf(wrapper.find("div.monkeys.as-error-fallback"), 1);
+ });
+
+ it("should call window.location.reload(true) if .reload-button clicked", () => {
+ const fakeWindow = { location: { reload: sinon.spy() } };
+ const wrapper = shallow(<ErrorBoundaryFallback windowObj={fakeWindow} />);
+
+ wrapper.find(".reload-button").simulate("click");
+
+ assert.calledOnce(fakeWindow.location.reload);
+ assert.calledWithExactly(fakeWindow.location.reload, true);
+ });
+
+ it("should render .reload-button as an <A11yLinkButton>", () => {
+ const wrapper = shallow(<ErrorBoundaryFallback />);
+
+ assert.lengthOf(wrapper.find("A11yLinkButton.reload-button"), 1);
+ });
+
+ it("should render newtab-error-fallback-refresh-link node", () => {
+ const wrapper = shallow(<ErrorBoundaryFallback />);
+
+ const msgWrapper = wrapper.find(
+ '[data-l10n-id="newtab-error-fallback-refresh-link"]'
+ );
+ assert.lengthOf(msgWrapper, 1);
+ assert.isTrue(msgWrapper.is(A11yLinkButton));
+ });
+
+ it("should render newtab-error-fallback-info node", () => {
+ const wrapper = shallow(<ErrorBoundaryFallback />);
+
+ const msgWrapper = wrapper.find(
+ '[data-l10n-id="newtab-error-fallback-info"]'
+ );
+ assert.lengthOf(msgWrapper, 1);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/FluentOrText.test.jsx b/browser/components/newtab/test/unit/content-src/components/FluentOrText.test.jsx
new file mode 100644
index 0000000000..165f2a6dcf
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/FluentOrText.test.jsx
@@ -0,0 +1,68 @@
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+import React from "react";
+import { shallow, mount } from "enzyme";
+
+describe("<FluentOrText>", () => {
+ it("should create span with no children", () => {
+ const wrapper = shallow(<FluentOrText />);
+
+ assert.ok(wrapper.find("span").exists());
+ });
+ it("should set plain text", () => {
+ const wrapper = shallow(<FluentOrText message={"hello"} />);
+
+ assert.equal(wrapper.text(), "hello");
+ });
+ it("should use fluent id on automatic span", () => {
+ const wrapper = shallow(<FluentOrText message={{ id: "fluent" }} />);
+
+ assert.ok(wrapper.find("span[data-l10n-id='fluent']").exists());
+ });
+ it("should also allow string_id", () => {
+ const wrapper = shallow(<FluentOrText message={{ string_id: "fluent" }} />);
+
+ assert.ok(wrapper.find("span[data-l10n-id='fluent']").exists());
+ });
+ it("should use fluent id on child", () => {
+ const wrapper = shallow(
+ <FluentOrText message={{ id: "fluent" }}>
+ <p />
+ </FluentOrText>
+ );
+
+ assert.ok(wrapper.find("p[data-l10n-id='fluent']").exists());
+ });
+ it("should set args for fluent", () => {
+ const wrapper = mount(<FluentOrText message={{ args: { num: 5 } }} />);
+ const { attributes } = wrapper.getDOMNode();
+ const args = attributes.getNamedItem("data-l10n-args").value;
+ assert.equal(JSON.parse(args).num, 5);
+ });
+ it("should also allow values", () => {
+ const wrapper = mount(<FluentOrText message={{ values: { num: 5 } }} />);
+ const { attributes } = wrapper.getDOMNode();
+ const args = attributes.getNamedItem("data-l10n-args").value;
+ assert.equal(JSON.parse(args).num, 5);
+ });
+ it("should preserve original children with fluent", () => {
+ const wrapper = shallow(
+ <FluentOrText message={{ id: "fluent" }}>
+ <p>
+ <b data-l10n-name="bold" />
+ </p>
+ </FluentOrText>
+ );
+
+ assert.ok(wrapper.find("b[data-l10n-name='bold']").exists());
+ });
+ it("should only allow a single child", () => {
+ assert.throws(() =>
+ shallow(
+ <FluentOrText>
+ <p />
+ <p />
+ </FluentOrText>
+ )
+ );
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/HelpText.test.jsx b/browser/components/newtab/test/unit/content-src/components/HelpText.test.jsx
new file mode 100644
index 0000000000..e2cf4f1f21
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/HelpText.test.jsx
@@ -0,0 +1,41 @@
+import { HelpText } from "content-src/aboutwelcome/components/HelpText";
+import { Localized } from "content-src/aboutwelcome/components/MSLocalized";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<HelpText>", () => {
+ it("should render text inside Localized", () => {
+ const shallowWrapper = shallow(<HelpText text="test" />);
+
+ assert.equal(shallowWrapper.find(Localized).props().text, "test");
+ });
+ it("should render the img if there is an img and a string_id", () => {
+ const shallowWrapper = shallow(
+ <HelpText
+ text={{ string_id: "test_id" }}
+ hasImg={{
+ src: "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png",
+ }}
+ />
+ );
+ assert.ok(
+ shallowWrapper
+ .find(Localized)
+ .findWhere(n => n.text.string_id === "test_id")
+ );
+ assert.lengthOf(shallowWrapper.find("p.helptext"), 1);
+ assert.lengthOf(shallowWrapper.find("img[data-l10n-name='help-img']"), 1);
+ });
+ it("should render the img if there is an img and plain text", () => {
+ const shallowWrapper = shallow(
+ <HelpText
+ text={"Sample help text"}
+ hasImg={{
+ src: "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png",
+ }}
+ />
+ );
+ assert.equal(shallowWrapper.find("p.helptext").text(), "Sample help text");
+ assert.lengthOf(shallowWrapper.find("img.helptext-img"), 1);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx
new file mode 100644
index 0000000000..8aa74a3a46
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx
@@ -0,0 +1,582 @@
+import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu";
+import { _LinkMenu as LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<LinkMenu>", () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "" }}
+ options={["CheckPinTopSite", "CheckBookmark", "OpenInNewWindow"]}
+ dispatch={() => {}}
+ />
+ );
+ });
+ it("should render a ContextMenu element", () => {
+ assert.ok(wrapper.find(ContextMenu).exists());
+ });
+ it("should pass onUpdate, and options to ContextMenu", () => {
+ assert.ok(wrapper.find(ContextMenu).exists());
+ const contextMenuProps = wrapper.find(ContextMenu).props();
+ ["onUpdate", "options"].forEach(prop =>
+ assert.property(contextMenuProps, prop)
+ );
+ });
+ it("should give ContextMenu the correct tabbable options length for a11y", () => {
+ const { options } = wrapper.find(ContextMenu).props();
+ const [firstItem] = options;
+ const lastItem = options[options.length - 1];
+
+ // first item should have {first: true}
+ assert.isTrue(firstItem.first);
+ assert.ok(!firstItem.last);
+
+ // last item should have {last: true}
+ assert.isTrue(lastItem.last);
+ assert.ok(!lastItem.first);
+
+ // middle items should have neither
+ for (let i = 1; i < options.length - 1; i++) {
+ assert.ok(!options[i].first && !options[i].last);
+ }
+ });
+ it("should show the correct options for default sites", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", isDefault: true }}
+ options={["CheckBookmark"]}
+ source={"TOP_SITES"}
+ isPrivateBrowsingEnabled={true}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ let i = 0;
+ assert.propertyVal(options[i++], "id", "newtab-menu-pin");
+ assert.propertyVal(options[i++], "id", "newtab-menu-edit-topsites");
+ assert.propertyVal(options[i++], "type", "separator");
+ assert.propertyVal(options[i++], "id", "newtab-menu-open-new-window");
+ assert.propertyVal(
+ options[i++],
+ "id",
+ "newtab-menu-open-new-private-window"
+ );
+ assert.propertyVal(options[i++], "type", "separator");
+ assert.propertyVal(options[i++], "id", "newtab-menu-dismiss");
+ assert.propertyVal(options, "length", i);
+ // Double check that delete options are not included for default top sites
+ options
+ .filter(o => o.type !== "separator")
+ .forEach(o => {
+ assert.notInclude(["newtab-menu-delete-history"], o.id);
+ });
+ });
+ it("should show Unpin option for a pinned site if CheckPinTopSite in options list", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", isPinned: true }}
+ source={"TOP_SITES"}
+ options={["CheckPinTopSite"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(options.find(o => o.id && o.id === "newtab-menu-unpin"));
+ });
+ it("should show Pin option for an unpinned site if CheckPinTopSite in options list", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", isPinned: false }}
+ source={"TOP_SITES"}
+ options={["CheckPinTopSite"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(options.find(o => o.id && o.id === "newtab-menu-pin"));
+ });
+ it("should show Unbookmark option for a bookmarked site if CheckBookmark in options list", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", bookmarkGuid: 1234 }}
+ source={"TOP_SITES"}
+ options={["CheckBookmark"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-remove-bookmark")
+ );
+ });
+ it("should show Bookmark option for an unbookmarked site if CheckBookmark in options list", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", bookmarkGuid: 0 }}
+ source={"TOP_SITES"}
+ options={["CheckBookmark"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-bookmark")
+ );
+ });
+ it("should show Save to Pocket option for an unsaved Pocket item if CheckSavedToPocket in options list", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", bookmarkGuid: 0 }}
+ source={"HIGHLIGHTS"}
+ options={["CheckSavedToPocket"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-save-to-pocket")
+ );
+ });
+ it("should show Delete from Pocket option for a saved Pocket item if CheckSavedToPocket in options list", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", pocket_id: 1234 }}
+ source={"HIGHLIGHTS"}
+ options={["CheckSavedToPocket"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-delete-pocket")
+ );
+ });
+ it("should show Archive from Pocket option for a saved Pocket item if CheckBookmarkOrArchive", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", pocket_id: 1234 }}
+ source={"HIGHLIGHTS"}
+ options={["CheckBookmarkOrArchive"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-archive-pocket")
+ );
+ });
+ it("should show Bookmark option for an unbookmarked site if CheckBookmarkOrArchive in options list and no pocket_id", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "" }}
+ source={"HIGHLIGHTS"}
+ options={["CheckBookmarkOrArchive"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-bookmark")
+ );
+ });
+ it("should show Unbookmark option for a bookmarked site if CheckBookmarkOrArchive in options list and no pocket_id", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", bookmarkGuid: 1234 }}
+ source={"HIGHLIGHTS"}
+ options={["CheckBookmarkOrArchive"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-remove-bookmark")
+ );
+ });
+ it("should show Archive from Pocket option for a saved Pocket item if CheckArchiveFromPocket", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", pocket_id: 1234 }}
+ source={"TOP_STORIES"}
+ options={["CheckArchiveFromPocket"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-archive-pocket")
+ );
+ });
+ it("should show empty from no Pocket option for no saved Pocket item if CheckArchiveFromPocket", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "" }}
+ source={"TOP_STORIES"}
+ options={["CheckArchiveFromPocket"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isUndefined(
+ options.find(o => o.id && o.id === "newtab-menu-archive-pocket")
+ );
+ });
+ it("should show Delete from Pocket option for a saved Pocket item if CheckDeleteFromPocket", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", pocket_id: 1234 }}
+ source={"TOP_STORIES"}
+ options={["CheckDeleteFromPocket"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-delete-pocket")
+ );
+ });
+ it("should show empty from Pocket option for no saved Pocket item if CheckDeleteFromPocket", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "" }}
+ source={"TOP_STORIES"}
+ options={["CheckDeleteFromPocket"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isUndefined(
+ options.find(o => o.id && o.id === "newtab-menu-archive-pocket")
+ );
+ });
+ it("should show Open File option for a downloaded item", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", type: "download", path: "foo" }}
+ source={"HIGHLIGHTS"}
+ options={["OpenFile"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-open-file")
+ );
+ });
+ it("should show Show File option for a downloaded item on a default platform", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", type: "download", path: "foo" }}
+ source={"HIGHLIGHTS"}
+ options={["ShowFile"]}
+ platform={"default"}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-show-file")
+ );
+ });
+ it("should show Copy Downlad Link option for a downloaded item when CopyDownloadLink", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", type: "download" }}
+ source={"HIGHLIGHTS"}
+ options={["CopyDownloadLink"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-copy-download-link")
+ );
+ });
+ it("should show Go To Download Page option for a downloaded item when GoToDownloadPage", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", type: "download", referrer: "foo" }}
+ source={"HIGHLIGHTS"}
+ options={["GoToDownloadPage"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-go-to-download-page")
+ );
+ assert.isFalse(options[0].disabled);
+ });
+ it("should show Go To Download Page option as disabled for a downloaded item when GoToDownloadPage if no referrer exists", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", type: "download", referrer: null }}
+ source={"HIGHLIGHTS"}
+ options={["GoToDownloadPage"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-go-to-download-page")
+ );
+ assert.isTrue(options[0].disabled);
+ });
+ it("should show Remove Download Link option for a downloaded item when RemoveDownload", () => {
+ wrapper = shallow(
+ <LinkMenu
+ site={{ url: "", type: "download" }}
+ source={"HIGHLIGHTS"}
+ options={["RemoveDownload"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ assert.isDefined(
+ options.find(o => o.id && o.id === "newtab-menu-remove-download")
+ );
+ });
+ it("should show Edit option", () => {
+ const props = { url: "foo", label: "label" };
+ const index = 5;
+ wrapper = shallow(
+ <LinkMenu
+ site={props}
+ index={5}
+ source={"TOP_SITES"}
+ options={["EditTopSite"]}
+ dispatch={() => {}}
+ />
+ );
+ const { options } = wrapper.find(ContextMenu).props();
+ const option = options.find(
+ o => o.id && o.id === "newtab-menu-edit-topsites"
+ );
+ assert.isDefined(option);
+ assert.equal(option.action.data.index, index);
+ });
+ describe(".onClick", () => {
+ const FAKE_EVENT = {};
+ const FAKE_INDEX = 3;
+ const FAKE_SOURCE = "TOP_SITES";
+ const FAKE_SITE = {
+ bookmarkGuid: 1234,
+ hostname: "foo",
+ path: "foo",
+ pocket_id: "1234",
+ referrer: "https://foo.com/ref",
+ title: "bar",
+ type: "bookmark",
+ typedBonus: true,
+ url: "https://foo.com",
+ sponsored_tile_id: 12345,
+ };
+ const dispatch = sinon.stub();
+ const propOptions = [
+ "ShowFile",
+ "CopyDownloadLink",
+ "GoToDownloadPage",
+ "RemoveDownload",
+ "Separator",
+ "ShowPrivacyInfo",
+ "RemoveBookmark",
+ "AddBookmark",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "BlockUrl",
+ "DeleteUrl",
+ "PinTopSite",
+ "UnpinTopSite",
+ "SaveToPocket",
+ "DeleteFromPocket",
+ "ArchiveFromPocket",
+ "WebExtDismiss",
+ ];
+ const expectedActionData = {
+ "newtab-menu-remove-bookmark": FAKE_SITE.bookmarkGuid,
+ "newtab-menu-bookmark": {
+ url: FAKE_SITE.url,
+ title: FAKE_SITE.title,
+ type: FAKE_SITE.type,
+ },
+ "newtab-menu-open-new-window": {
+ url: FAKE_SITE.url,
+ referrer: FAKE_SITE.referrer,
+ typedBonus: FAKE_SITE.typedBonus,
+ sponsored_tile_id: FAKE_SITE.sponsored_tile_id,
+ },
+ "newtab-menu-open-new-private-window": {
+ url: FAKE_SITE.url,
+ referrer: FAKE_SITE.referrer,
+ },
+ "newtab-menu-dismiss": [
+ {
+ url: FAKE_SITE.url,
+ pocket_id: FAKE_SITE.pocket_id,
+ isSponsoredTopSite: undefined,
+ },
+ ],
+ menu_action_webext_dismiss: {
+ source: "TOP_SITES",
+ url: FAKE_SITE.url,
+ action_position: 3,
+ },
+ "newtab-menu-delete-history": {
+ url: FAKE_SITE.url,
+ pocket_id: FAKE_SITE.pocket_id,
+ forceBlock: FAKE_SITE.bookmarkGuid,
+ },
+ "newtab-menu-pin": { site: FAKE_SITE, index: FAKE_INDEX },
+ "newtab-menu-unpin": { site: { url: FAKE_SITE.url } },
+ "newtab-menu-save-to-pocket": {
+ site: { url: FAKE_SITE.url, title: FAKE_SITE.title },
+ },
+ "newtab-menu-delete-pocket": { pocket_id: "1234" },
+ "newtab-menu-archive-pocket": { pocket_id: "1234" },
+ "newtab-menu-show-file": { url: FAKE_SITE.url },
+ "newtab-menu-copy-download-link": { url: FAKE_SITE.url },
+ "newtab-menu-go-to-download-page": { url: FAKE_SITE.referrer },
+ "newtab-menu-remove-download": { url: FAKE_SITE.url },
+ };
+ const { options } = shallow(
+ <LinkMenu
+ site={FAKE_SITE}
+ siteInfo={{ value: { card_type: FAKE_SITE.type } }}
+ dispatch={dispatch}
+ index={FAKE_INDEX}
+ isPrivateBrowsingEnabled={true}
+ platform={"default"}
+ options={propOptions}
+ source={FAKE_SOURCE}
+ shouldSendImpressionStats={true}
+ />
+ )
+ .find(ContextMenu)
+ .props();
+ afterEach(() => dispatch.reset());
+ options
+ .filter(o => o.type !== "separator")
+ .forEach(option => {
+ it(`should fire a ${option.action.type} action for ${option.id} with the expected data`, () => {
+ option.onClick(FAKE_EVENT);
+
+ if (option.impression && option.userEvent) {
+ assert.calledThrice(dispatch);
+ } else if (option.impression || option.userEvent) {
+ assert.calledTwice(dispatch);
+ } else {
+ assert.calledOnce(dispatch);
+ }
+
+ // option.action is dispatched
+ assert.ok(dispatch.firstCall.calledWith(option.action));
+
+ // option.action has correct data
+ // (delete is a special case as it dispatches a nested DIALOG_OPEN-type action)
+ // in the case of this FAKE_SITE, we send a bookmarkGuid therefore we also want
+ // to block this if we delete it
+ if (option.id === "newtab-menu-delete-history") {
+ assert.deepEqual(
+ option.action.data.onConfirm[0].data,
+ expectedActionData[option.id]
+ );
+ // Test UserEvent send correct meta about item deleted
+ assert.propertyVal(
+ option.action.data.onConfirm[1].data,
+ "action_position",
+ FAKE_INDEX
+ );
+ assert.propertyVal(
+ option.action.data.onConfirm[1].data,
+ "source",
+ FAKE_SOURCE
+ );
+ } else {
+ assert.deepEqual(option.action.data, expectedActionData[option.id]);
+ }
+ });
+ it(`should fire a UserEvent action for ${option.id} if configured`, () => {
+ if (option.userEvent) {
+ option.onClick(FAKE_EVENT);
+ const [action] = dispatch.secondCall.args;
+ assert.isUserEventAction(action);
+ assert.propertyVal(action.data, "source", FAKE_SOURCE);
+ assert.propertyVal(action.data, "action_position", FAKE_INDEX);
+ assert.propertyVal(action.data.value, "card_type", FAKE_SITE.type);
+ }
+ });
+ it(`should send impression stats for ${option.id}`, () => {
+ if (option.impression) {
+ option.onClick(FAKE_EVENT);
+ const [action] = dispatch.thirdCall.args;
+ assert.deepEqual(action, option.impression);
+ }
+ });
+ });
+ it(`should not send impression stats if not configured`, () => {
+ const fakeOptions = shallow(
+ <LinkMenu
+ site={FAKE_SITE}
+ dispatch={dispatch}
+ index={FAKE_INDEX}
+ options={propOptions}
+ source={FAKE_SOURCE}
+ shouldSendImpressionStats={false}
+ />
+ )
+ .find(ContextMenu)
+ .props().options;
+
+ fakeOptions
+ .filter(o => o.type !== "separator")
+ .forEach(option => {
+ if (option.impression) {
+ option.onClick(FAKE_EVENT);
+ assert.calledTwice(dispatch);
+ assert.notEqual(dispatch.firstCall.args[0], option.impression);
+ assert.notEqual(dispatch.secondCall.args[0], option.impression);
+ dispatch.reset();
+ }
+ });
+ });
+ it(`should pin a SPOC with all of the site details sent`, () => {
+ const pinSpocTopSite = "PinTopSite";
+ const { options: spocOptions } = shallow(
+ <LinkMenu
+ site={FAKE_SITE}
+ siteInfo={{ value: { card_type: FAKE_SITE.type } }}
+ dispatch={dispatch}
+ index={FAKE_INDEX}
+ isPrivateBrowsingEnabled={true}
+ platform={"default"}
+ options={[pinSpocTopSite]}
+ source={FAKE_SOURCE}
+ shouldSendImpressionStats={true}
+ />
+ )
+ .find(ContextMenu)
+ .props();
+
+ const [pinSpocOption] = spocOptions;
+ pinSpocOption.onClick(FAKE_EVENT);
+
+ if (pinSpocOption.impression && pinSpocOption.userEvent) {
+ assert.calledThrice(dispatch);
+ } else if (pinSpocOption.impression || pinSpocOption.userEvent) {
+ assert.calledTwice(dispatch);
+ } else {
+ assert.calledOnce(dispatch);
+ }
+
+ // option.action is dispatched
+ assert.ok(dispatch.firstCall.calledWith(pinSpocOption.action));
+
+ assert.deepEqual(pinSpocOption.action.data, {
+ site: FAKE_SITE,
+ index: FAKE_INDEX,
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/MSLocalized.test.jsx b/browser/components/newtab/test/unit/content-src/components/MSLocalized.test.jsx
new file mode 100644
index 0000000000..d46f794513
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/MSLocalized.test.jsx
@@ -0,0 +1,48 @@
+import { Localized } from "content-src/aboutwelcome/components/MSLocalized";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<MSLocalized>", () => {
+ it("should render span with no children", () => {
+ const shallowWrapper = shallow(<Localized text="test" />);
+
+ assert.ok(shallowWrapper.find("span").exists());
+ assert.equal(shallowWrapper.text(), "test");
+ });
+ it("should render span when using string_id with no children", () => {
+ const shallowWrapper = shallow(
+ <Localized text={{ string_id: "test_id" }} />
+ );
+
+ assert.ok(shallowWrapper.find("span[data-l10n-id='test_id']").exists());
+ });
+ it("should render text inside child", () => {
+ const shallowWrapper = shallow(
+ <Localized text="test">
+ <div />
+ </Localized>
+ );
+
+ assert.ok(shallowWrapper.find("div").text(), "test");
+ });
+ it("should use l10n id on child", () => {
+ const shallowWrapper = shallow(
+ <Localized text={{ string_id: "test_id" }}>
+ <div />
+ </Localized>
+ );
+
+ assert.ok(shallowWrapper.find("div[data-l10n-id='test_id']").exists());
+ });
+ it("should keep original children", () => {
+ const shallowWrapper = shallow(
+ <Localized text={{ string_id: "test_id" }}>
+ <h1>
+ <span data-l10n-name="test" />
+ </h1>
+ </Localized>
+ );
+
+ assert.ok(shallowWrapper.find("span[data-l10n-name='test']").exists());
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/MoreRecommendations.test.jsx b/browser/components/newtab/test/unit/content-src/components/MoreRecommendations.test.jsx
new file mode 100644
index 0000000000..2b3c06b6bf
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/MoreRecommendations.test.jsx
@@ -0,0 +1,24 @@
+import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<MoreRecommendations>", () => {
+ it("should render a MoreRecommendations element", () => {
+ const wrapper = shallow(<MoreRecommendations />);
+ assert.ok(wrapper.exists());
+ });
+ it("should render a link when provided with read_more_endpoint prop", () => {
+ const wrapper = shallow(
+ <MoreRecommendations read_more_endpoint="https://endpoint.com" />
+ );
+
+ const link = wrapper.find(".more-recommendations");
+ assert.lengthOf(link, 1);
+ });
+ it("should not render a link when provided with read_more_endpoint prop", () => {
+ const wrapper = shallow(<MoreRecommendations read_more_endpoint="" />);
+
+ const link = wrapper.find(".more-recommendations");
+ assert.lengthOf(link, 0);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/PocketLoggedInCta.test.jsx b/browser/components/newtab/test/unit/content-src/components/PocketLoggedInCta.test.jsx
new file mode 100644
index 0000000000..31a5e7be4d
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/PocketLoggedInCta.test.jsx
@@ -0,0 +1,46 @@
+import { combineReducers, createStore } from "redux";
+import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
+import { mount, shallow } from "enzyme";
+import {
+ PocketLoggedInCta,
+ _PocketLoggedInCta as PocketLoggedInCtaRaw,
+} from "content-src/components/PocketLoggedInCta/PocketLoggedInCta";
+import { Provider } from "react-redux";
+import React from "react";
+
+function mountSectionWithProps(props) {
+ const store = createStore(combineReducers(reducers), INITIAL_STATE);
+ return mount(
+ <Provider store={store}>
+ <PocketLoggedInCta {...props} />
+ </Provider>
+ );
+}
+
+describe("<PocketLoggedInCta>", () => {
+ it("should render a PocketLoggedInCta element", () => {
+ const wrapper = mountSectionWithProps({});
+ assert.ok(wrapper.exists());
+ });
+ it("should render Fluent spans when rendered without props", () => {
+ const wrapper = mountSectionWithProps({});
+
+ const message = wrapper.find("span[data-l10n-id]");
+ assert.lengthOf(message, 2);
+ });
+ it("should not render Fluent spans when rendered with props", () => {
+ const wrapper = shallow(
+ <PocketLoggedInCtaRaw
+ Pocket={{
+ pocketCta: {
+ ctaButton: "button",
+ ctaText: "text",
+ },
+ }}
+ />
+ );
+
+ const message = wrapper.find("span[data-l10n-id]");
+ assert.lengthOf(message, 0);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/Search.test.jsx b/browser/components/newtab/test/unit/content-src/components/Search.test.jsx
new file mode 100644
index 0000000000..54a3b611cc
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/Search.test.jsx
@@ -0,0 +1,179 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { mount, shallow } from "enzyme";
+import React from "react";
+import { _Search as Search } from "content-src/components/Search/Search";
+
+const DEFAULT_PROPS = {
+ dispatch() {},
+ Prefs: { values: { featureConfig: {} } },
+};
+
+describe("<Search>", () => {
+ let globals;
+ let sandbox;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+
+ global.ContentSearchUIController.prototype = { search: sandbox.spy() };
+ });
+ afterEach(() => {
+ globals.restore();
+ });
+
+ it("should render a Search element", () => {
+ const wrapper = shallow(<Search {...DEFAULT_PROPS} />);
+ assert.ok(wrapper.exists());
+ });
+ it("should not use a <form> element", () => {
+ const wrapper = mount(<Search {...DEFAULT_PROPS} />);
+
+ assert.equal(wrapper.find("form").length, 0);
+ });
+ it("should listen for ContentSearchClient on render", () => {
+ const spy = globals.set("addEventListener", sandbox.spy());
+
+ const wrapper = mount(<Search {...DEFAULT_PROPS} />);
+
+ assert.calledOnce(spy.withArgs("ContentSearchClient", wrapper.instance()));
+ });
+ it("should stop listening for ContentSearchClient on unmount", () => {
+ const spy = globals.set("removeEventListener", sandbox.spy());
+ const wrapper = mount(<Search {...DEFAULT_PROPS} />);
+ // cache the instance as we can't call this method after unmount is called
+ const instance = wrapper.instance();
+
+ wrapper.unmount();
+
+ assert.calledOnce(spy.withArgs("ContentSearchClient", instance));
+ });
+ it("should add gContentSearchController as a global", () => {
+ // current about:home tests need gContentSearchController to exist as a global
+ // so let's test it here too to ensure we don't break this behaviour
+ mount(<Search {...DEFAULT_PROPS} />);
+ assert.property(window, "gContentSearchController");
+ assert.ok(window.gContentSearchController);
+ });
+ it("should pass along search when clicking the search button", () => {
+ const wrapper = mount(<Search {...DEFAULT_PROPS} />);
+
+ wrapper.find(".search-button").simulate("click");
+
+ const { search } = window.gContentSearchController;
+ assert.calledOnce(search);
+ assert.propertyVal(search.firstCall.args[0], "type", "click");
+ });
+ it("should send a UserEvent action", () => {
+ global.ContentSearchUIController.prototype.search = () => {
+ dispatchEvent(
+ new CustomEvent("ContentSearchClient", { detail: { type: "Search" } })
+ );
+ };
+ const dispatch = sinon.spy();
+ const wrapper = mount(<Search {...DEFAULT_PROPS} dispatch={dispatch} />);
+
+ wrapper.find(".search-button").simulate("click");
+
+ assert.calledOnce(dispatch);
+ const [action] = dispatch.firstCall.args;
+ assert.isUserEventAction(action);
+ assert.propertyVal(action.data, "event", "SEARCH");
+ });
+ it("should show our logo when the prop exists.", () => {
+ const showLogoProps = Object.assign({}, DEFAULT_PROPS, { showLogo: true });
+
+ const wrapper = shallow(<Search {...showLogoProps} />);
+ assert.lengthOf(wrapper.find(".logo-and-wordmark"), 1);
+ });
+ it("should not show our logo when the prop does not exist.", () => {
+ const hideLogoProps = Object.assign({}, DEFAULT_PROPS, { showLogo: false });
+
+ const wrapper = shallow(<Search {...hideLogoProps} />);
+ assert.lengthOf(wrapper.find(".logo-and-wordmark"), 0);
+ });
+
+ describe("Search Hand-off", () => {
+ it("should render a Search element when hand-off is enabled", () => {
+ const wrapper = shallow(
+ <Search {...DEFAULT_PROPS} handoffEnabled={true} />
+ );
+ assert.ok(wrapper.exists());
+ assert.equal(wrapper.find(".search-handoff-button").length, 1);
+ });
+ it("should hand-off search when button is clicked", () => {
+ const dispatch = sinon.spy();
+ const wrapper = shallow(
+ <Search {...DEFAULT_PROPS} handoffEnabled={true} dispatch={dispatch} />
+ );
+ wrapper
+ .find(".search-handoff-button")
+ .simulate("click", { preventDefault: () => {} });
+ assert.calledThrice(dispatch);
+ assert.calledWith(dispatch, {
+ data: { text: undefined },
+ meta: {
+ from: "ActivityStream:Content",
+ skipLocal: true,
+ to: "ActivityStream:Main",
+ },
+ type: "HANDOFF_SEARCH_TO_AWESOMEBAR",
+ });
+ assert.calledWith(dispatch, { type: "FAKE_FOCUS_SEARCH" });
+ const [action] = dispatch.thirdCall.args;
+ assert.isUserEventAction(action);
+ assert.propertyVal(action.data, "event", "SEARCH_HANDOFF");
+ });
+ it("should hand-off search on paste", () => {
+ const dispatch = sinon.spy();
+ const wrapper = mount(
+ <Search {...DEFAULT_PROPS} handoffEnabled={true} dispatch={dispatch} />
+ );
+ wrapper.instance()._searchHandoffButton = { contains: () => true };
+ wrapper.instance().onSearchHandoffPaste({
+ clipboardData: {
+ getData: () => "some copied text",
+ },
+ preventDefault: () => {},
+ });
+ assert.equal(dispatch.callCount, 4);
+ assert.calledWith(dispatch, {
+ data: { text: "some copied text" },
+ meta: {
+ from: "ActivityStream:Content",
+ skipLocal: true,
+ to: "ActivityStream:Main",
+ },
+ type: "HANDOFF_SEARCH_TO_AWESOMEBAR",
+ });
+ assert.calledWith(dispatch, { type: "DISABLE_SEARCH" });
+ const [action] = dispatch.thirdCall.args;
+ assert.isUserEventAction(action);
+ assert.propertyVal(action.data, "event", "SEARCH_HANDOFF");
+ });
+ it("should properly handle drop events", () => {
+ const dispatch = sinon.spy();
+ const wrapper = mount(
+ <Search {...DEFAULT_PROPS} handoffEnabled={true} dispatch={dispatch} />
+ );
+ const preventDefault = sinon.spy();
+ wrapper.find(".fake-editable").simulate("drop", {
+ dataTransfer: { getData: () => "dropped text" },
+ preventDefault,
+ });
+ assert.equal(dispatch.callCount, 4);
+ assert.calledWith(dispatch, {
+ data: { text: "dropped text" },
+ meta: {
+ from: "ActivityStream:Content",
+ skipLocal: true,
+ to: "ActivityStream:Main",
+ },
+ type: "HANDOFF_SEARCH_TO_AWESOMEBAR",
+ });
+ assert.calledWith(dispatch, { type: "DISABLE_SEARCH" });
+ const [action] = dispatch.thirdCall.args;
+ assert.isUserEventAction(action);
+ assert.propertyVal(action.data, "event", "SEARCH_HANDOFF");
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx b/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx
new file mode 100644
index 0000000000..9f4008369a
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx
@@ -0,0 +1,600 @@
+import { combineReducers, createStore } from "redux";
+import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
+import {
+ Section,
+ SectionIntl,
+ _Sections as Sections,
+} from "content-src/components/Sections/Sections";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { mount, shallow } from "enzyme";
+import { PlaceholderCard } from "content-src/components/Card/Card";
+import { PocketLoggedInCta } from "content-src/components/PocketLoggedInCta/PocketLoggedInCta";
+import { Provider } from "react-redux";
+import React from "react";
+import { Topics } from "content-src/components/Topics/Topics";
+import { TopSites } from "content-src/components/TopSites/TopSites";
+
+function mountSectionWithProps(props) {
+ const store = createStore(combineReducers(reducers), INITIAL_STATE);
+ return mount(
+ <Provider store={store}>
+ <Section {...props} />
+ </Provider>
+ );
+}
+
+function mountSectionIntlWithProps(props) {
+ const store = createStore(combineReducers(reducers), INITIAL_STATE);
+ return mount(
+ <Provider store={store}>
+ <SectionIntl {...props} />
+ </Provider>
+ );
+}
+
+describe("<Sections>", () => {
+ let wrapper;
+ let FAKE_SECTIONS;
+ beforeEach(() => {
+ FAKE_SECTIONS = new Array(5).fill(null).map((v, i) => ({
+ id: `foo_bar_${i}`,
+ title: `Foo Bar ${i}`,
+ enabled: !!(i % 2),
+ rows: [],
+ }));
+ wrapper = shallow(
+ <Sections
+ Sections={FAKE_SECTIONS}
+ Prefs={{
+ values: { sectionOrder: FAKE_SECTIONS.map(i => i.id).join(",") },
+ }}
+ />
+ );
+ });
+ it("should render a Sections element", () => {
+ assert.ok(wrapper.exists());
+ });
+ it("should render a Section for each one passed in props.Sections with .enabled === true", () => {
+ const sectionElems = wrapper.find(SectionIntl);
+ assert.lengthOf(sectionElems, 2);
+ sectionElems.forEach((section, i) => {
+ assert.equal(section.props().id, FAKE_SECTIONS[2 * i + 1].id);
+ assert.equal(section.props().enabled, true);
+ });
+ });
+ it("should render Top Sites if feeds.topsites pref is true", () => {
+ wrapper = shallow(
+ <Sections
+ Sections={FAKE_SECTIONS}
+ Prefs={{
+ values: {
+ "feeds.topsites": true,
+ sectionOrder: "topsites,topstories,highlights",
+ },
+ }}
+ />
+ );
+ assert.equal(wrapper.find(TopSites).length, 1);
+ });
+ it("should NOT render Top Sites if feeds.topsites pref is false", () => {
+ wrapper = shallow(
+ <Sections
+ Sections={FAKE_SECTIONS}
+ Prefs={{
+ values: {
+ "feeds.topsites": false,
+ sectionOrder: "topsites,topstories,highlights",
+ },
+ }}
+ />
+ );
+ assert.equal(wrapper.find(TopSites).length, 0);
+ });
+ it("should render the sections in the order specifed by sectionOrder pref", () => {
+ wrapper = shallow(
+ <Sections
+ Sections={FAKE_SECTIONS}
+ Prefs={{ values: { sectionOrder: "foo_bar_1,foo_bar_3" } }}
+ />
+ );
+ let sections = wrapper.find(SectionIntl);
+ assert.lengthOf(sections, 2);
+ assert.equal(sections.first().props().id, "foo_bar_1");
+ assert.equal(sections.last().props().id, "foo_bar_3");
+ wrapper = shallow(
+ <Sections
+ Sections={FAKE_SECTIONS}
+ Prefs={{ values: { sectionOrder: "foo_bar_3,foo_bar_1" } }}
+ />
+ );
+ sections = wrapper.find(SectionIntl);
+ assert.lengthOf(sections, 2);
+ assert.equal(sections.first().props().id, "foo_bar_3");
+ assert.equal(sections.last().props().id, "foo_bar_1");
+ });
+});
+
+describe("<Section>", () => {
+ let wrapper;
+ let FAKE_SECTION;
+
+ beforeEach(() => {
+ FAKE_SECTION = {
+ id: `foo_bar_1`,
+ pref: { collapsed: false },
+ title: `Foo Bar 1`,
+ rows: [{ link: "http://localhost", index: 0 }],
+ emptyState: {
+ icon: "check",
+ message: "Some message",
+ },
+ rowsPref: "section.rows",
+ maxRows: 4,
+ Prefs: { values: { "section.rows": 2 } },
+ };
+ wrapper = mountSectionIntlWithProps(FAKE_SECTION);
+ });
+
+ describe("placeholders", () => {
+ const CARDS_PER_ROW = 3;
+ const fakeSite = { link: "http://localhost" };
+ function renderWithSites(rows) {
+ const store = createStore(combineReducers(reducers), INITIAL_STATE);
+ return mount(
+ <Provider store={store}>
+ <Section {...FAKE_SECTION} rows={rows} />
+ </Provider>
+ );
+ }
+
+ it("should return 2 row of placeholders if realRows is 0", () => {
+ wrapper = renderWithSites([]);
+ assert.lengthOf(wrapper.find(PlaceholderCard), 6);
+ });
+ it("should fill in the rest of the rows", () => {
+ wrapper = renderWithSites(new Array(CARDS_PER_ROW).fill(fakeSite));
+ assert.lengthOf(
+ wrapper.find(PlaceholderCard),
+ CARDS_PER_ROW,
+ "CARDS_PER_ROW"
+ );
+
+ wrapper = renderWithSites(new Array(CARDS_PER_ROW + 1).fill(fakeSite));
+ assert.lengthOf(wrapper.find(PlaceholderCard), 2, "CARDS_PER_ROW + 1");
+
+ wrapper = renderWithSites(new Array(CARDS_PER_ROW + 2).fill(fakeSite));
+ assert.lengthOf(wrapper.find(PlaceholderCard), 1, "CARDS_PER_ROW + 2");
+
+ wrapper = renderWithSites(
+ new Array(2 * CARDS_PER_ROW - 1).fill(fakeSite)
+ );
+ assert.lengthOf(wrapper.find(PlaceholderCard), 1, "CARDS_PER_ROW - 1");
+ });
+ it("should not add placeholders all the rows are full", () => {
+ wrapper = renderWithSites(new Array(2 * CARDS_PER_ROW).fill(fakeSite));
+ assert.lengthOf(wrapper.find(PlaceholderCard), 0, "2 rows");
+ });
+ });
+
+ describe("empty state", () => {
+ beforeEach(() => {
+ Object.assign(FAKE_SECTION, {
+ initialized: true,
+ dispatch: () => {},
+ rows: [],
+ emptyState: {
+ message: "Some message",
+ },
+ });
+ wrapper = shallow(<Section {...FAKE_SECTION} />);
+ });
+ it("should be shown when rows is empty and initialized is true", () => {
+ assert.ok(wrapper.find(".empty-state").exists());
+ });
+ it("should not be shown in initialized is false", () => {
+ Object.assign(FAKE_SECTION, {
+ initialized: false,
+ rows: [],
+ emptyState: {
+ message: "Some message",
+ },
+ });
+ wrapper = shallow(<Section {...FAKE_SECTION} />);
+ assert.isFalse(wrapper.find(".empty-state").exists());
+ });
+ it("no icon should be shown", () => {
+ assert.lengthOf(wrapper.find(".icon"), 0);
+ });
+ });
+
+ describe("topics component", () => {
+ let TOP_STORIES_SECTION;
+ beforeEach(() => {
+ TOP_STORIES_SECTION = {
+ id: "topstories",
+ title: "TopStories",
+ pref: { collapsed: false },
+ rows: [{ guid: 1, link: "http://localhost", isDefault: true }],
+ topics: [],
+ read_more_endpoint: "http://localhost/read-more",
+ maxRows: 1,
+ eventSource: "TOP_STORIES",
+ };
+ });
+ it("should not render for empty topics", () => {
+ wrapper = mountSectionIntlWithProps(TOP_STORIES_SECTION);
+
+ assert.lengthOf(wrapper.find(".topic"), 0);
+ });
+ it("should render for non-empty topics", () => {
+ TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }];
+ wrapper = shallow(
+ <Section
+ Pocket={{ pocketCta: { useCta: true }, isUserLoggedIn: true }}
+ {...TOP_STORIES_SECTION}
+ />
+ );
+
+ assert.lengthOf(wrapper.find(Topics), 1);
+ assert.lengthOf(wrapper.find(PocketLoggedInCta), 0);
+ });
+ it("should delay render of third rec to give time for potential spoc", async () => {
+ TOP_STORIES_SECTION.rows = [
+ { guid: 1, link: "http://localhost" },
+ { guid: 2, link: "http://localhost" },
+ { guid: 3, link: "http://localhost" },
+ ];
+ wrapper = shallow(
+ <Section
+ Pocket={{ waitingForSpoc: true, pocketCta: {} }}
+ {...TOP_STORIES_SECTION}
+ />
+ );
+ assert.lengthOf(wrapper.find(PlaceholderCard), 1);
+
+ wrapper.setProps({
+ Pocket: {
+ waitingForSpoc: false,
+ pocketCta: {},
+ },
+ });
+ assert.lengthOf(wrapper.find(PlaceholderCard), 0);
+ });
+ it("should render container for uninitialized topics to ensure content doesn't shift", () => {
+ delete TOP_STORIES_SECTION.topics;
+
+ wrapper = mountSectionIntlWithProps(TOP_STORIES_SECTION);
+
+ assert.lengthOf(wrapper.find(".top-stories-bottom-container"), 1);
+ assert.lengthOf(wrapper.find(Topics), 0);
+ assert.lengthOf(wrapper.find(PocketLoggedInCta), 0);
+ });
+
+ it("should render a pocket cta if not logged in and set to display cta", () => {
+ TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }];
+ wrapper = shallow(
+ <Section
+ Pocket={{ pocketCta: { useCta: true }, isUserLoggedIn: false }}
+ {...TOP_STORIES_SECTION}
+ />
+ );
+
+ assert.lengthOf(wrapper.find(Topics), 0);
+ assert.lengthOf(wrapper.find(PocketLoggedInCta), 1);
+ });
+ it("should render nothing while loading to avoid a flicker of log in state", () => {
+ TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }];
+ wrapper = shallow(
+ <Section
+ Pocket={{ pocketCta: { useCta: false } }}
+ {...TOP_STORIES_SECTION}
+ />
+ );
+
+ assert.lengthOf(wrapper.find(Topics), 0);
+ assert.lengthOf(wrapper.find(PocketLoggedInCta), 0);
+ });
+ it("should render a topics list if set to not display cta with either logged or out", () => {
+ TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }];
+ wrapper = shallow(
+ <Section
+ Pocket={{ pocketCta: { useCta: false }, isUserLoggedIn: false }}
+ {...TOP_STORIES_SECTION}
+ />
+ );
+
+ assert.lengthOf(wrapper.find(Topics), 1);
+ assert.lengthOf(wrapper.find(PocketLoggedInCta), 0);
+
+ wrapper = shallow(
+ <Section
+ Pocket={{ pocketCta: { useCta: false }, isUserLoggedIn: true }}
+ {...TOP_STORIES_SECTION}
+ />
+ );
+
+ assert.lengthOf(wrapper.find(Topics), 1);
+ assert.lengthOf(wrapper.find(PocketLoggedInCta), 0);
+ });
+ it("should render nothing if set to display a cta and not logged in or out (waiting for state)", () => {
+ TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }];
+ wrapper = shallow(
+ <Section
+ Pocket={{ pocketCta: { useCta: true } }}
+ {...TOP_STORIES_SECTION}
+ />
+ );
+
+ assert.lengthOf(wrapper.find(Topics), 0);
+ assert.lengthOf(wrapper.find(PocketLoggedInCta), 0);
+ });
+ });
+
+ describe("impression stats", () => {
+ const FAKE_TOPSTORIES_SECTION_PROPS = {
+ id: "TopStories",
+ title: "Foo Bar 1",
+ pref: { collapsed: false },
+ maxRows: 1,
+ rows: [{ guid: 1 }, { guid: 2 }],
+ shouldSendImpressionStats: true,
+
+ document: {
+ visibilityState: "visible",
+ addEventListener: sinon.stub(),
+ removeEventListener: sinon.stub(),
+ },
+ eventSource: "TOP_STORIES",
+ options: { personalized: false },
+ };
+
+ function renderSection(props = {}) {
+ return shallow(<Section {...FAKE_TOPSTORIES_SECTION_PROPS} {...props} />);
+ }
+
+ it("should send impression with the right stats when the page loads", () => {
+ const dispatch = sinon.spy();
+ renderSection({ dispatch });
+
+ assert.calledOnce(dispatch);
+
+ const [action] = dispatch.firstCall.args;
+ assert.equal(action.type, at.TELEMETRY_IMPRESSION_STATS);
+ assert.equal(action.data.source, "TOP_STORIES");
+ assert.deepEqual(action.data.tiles, [{ id: 1 }, { id: 2 }]);
+ });
+ it("should not send impression stats if not configured", () => {
+ const dispatch = sinon.spy();
+ const props = Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, {
+ shouldSendImpressionStats: false,
+ dispatch,
+ });
+ renderSection(props);
+ assert.notCalled(dispatch);
+ });
+ it("should not send impression stats if the section is collapsed", () => {
+ const dispatch = sinon.spy();
+ const props = Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, {
+ pref: { collapsed: true },
+ });
+ renderSection(props);
+ assert.notCalled(dispatch);
+ });
+ it("should send 1 impression when the page becomes visibile after loading", () => {
+ const props = {
+ dispatch: sinon.spy(),
+ document: {
+ visibilityState: "hidden",
+ addEventListener: sinon.spy(),
+ removeEventListener: sinon.spy(),
+ },
+ };
+
+ renderSection(props);
+
+ // Was the event listener added?
+ assert.calledWith(props.document.addEventListener, "visibilitychange");
+
+ // Make sure dispatch wasn't called yet
+ assert.notCalled(props.dispatch);
+
+ // Simulate a visibilityChange event
+ const [, listener] = props.document.addEventListener.firstCall.args;
+ props.document.visibilityState = "visible";
+ listener();
+
+ // Did we actually dispatch an event?
+ assert.calledOnce(props.dispatch);
+ assert.equal(
+ props.dispatch.firstCall.args[0].type,
+ at.TELEMETRY_IMPRESSION_STATS
+ );
+
+ // Did we remove the event listener?
+ assert.calledWith(
+ props.document.removeEventListener,
+ "visibilitychange",
+ listener
+ );
+ });
+ it("should remove visibility change listener when section is removed", () => {
+ const props = {
+ dispatch: sinon.spy(),
+ document: {
+ visibilityState: "hidden",
+ addEventListener: sinon.spy(),
+ removeEventListener: sinon.spy(),
+ },
+ };
+
+ const section = renderSection(props);
+ assert.calledWith(props.document.addEventListener, "visibilitychange");
+ const [, listener] = props.document.addEventListener.firstCall.args;
+
+ section.unmount();
+ assert.calledWith(
+ props.document.removeEventListener,
+ "visibilitychange",
+ listener
+ );
+ });
+ it("should send an impression if props are updated and props.rows are different", () => {
+ const props = { dispatch: sinon.spy() };
+ wrapper = renderSection(props);
+ props.dispatch.resetHistory();
+
+ // New rows
+ wrapper.setProps(
+ Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, {
+ rows: [{ guid: 123 }],
+ })
+ );
+
+ assert.calledOnce(props.dispatch);
+ });
+ it("should not send an impression if props are updated but props.rows are the same", () => {
+ const props = { dispatch: sinon.spy() };
+ wrapper = renderSection(props);
+ props.dispatch.resetHistory();
+
+ // Only update the disclaimer prop
+ wrapper.setProps(
+ Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, {
+ disclaimer: { id: "bar" },
+ })
+ );
+
+ assert.notCalled(props.dispatch);
+ });
+ it("should not send an impression if props are updated and props.rows are the same but section is collapsed", () => {
+ const props = { dispatch: sinon.spy() };
+ wrapper = renderSection(props);
+ props.dispatch.resetHistory();
+
+ // New rows and collapsed
+ wrapper.setProps(
+ Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, {
+ rows: [{ guid: 123 }],
+ pref: { collapsed: true },
+ })
+ );
+
+ assert.notCalled(props.dispatch);
+
+ // Expand the section. Now the impression stats should be sent
+ wrapper.setProps(
+ Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, {
+ rows: [{ guid: 123 }],
+ pref: { collapsed: false },
+ })
+ );
+
+ assert.calledOnce(props.dispatch);
+ });
+ it("should not send an impression if props are updated but GUIDs are the same", () => {
+ const props = { dispatch: sinon.spy() };
+ wrapper = renderSection(props);
+ props.dispatch.resetHistory();
+
+ wrapper.setProps(
+ Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, {
+ rows: [{ guid: 1 }, { guid: 2 }],
+ })
+ );
+
+ assert.notCalled(props.dispatch);
+ });
+ it("should only send the latest impression on a visibility change", () => {
+ const listeners = new Set();
+ const props = {
+ dispatch: sinon.spy(),
+ document: {
+ visibilityState: "hidden",
+ addEventListener: (ev, cb) => listeners.add(cb),
+ removeEventListener: (ev, cb) => listeners.delete(cb),
+ },
+ };
+
+ wrapper = renderSection(props);
+
+ // Update twice
+ wrapper.setProps(Object.assign({}, props, { rows: [{ guid: 123 }] }));
+ wrapper.setProps(Object.assign({}, props, { rows: [{ guid: 2432 }] }));
+
+ assert.notCalled(props.dispatch);
+
+ // Simulate listeners getting called
+ props.document.visibilityState = "visible";
+ listeners.forEach(l => l());
+
+ // Make sure we only sent the latest event
+ assert.calledOnce(props.dispatch);
+ const [action] = props.dispatch.firstCall.args;
+ assert.deepEqual(action.data.tiles, [{ id: 2432 }]);
+ });
+ });
+
+ describe("tab rehydrated", () => {
+ it("should fire NEW_TAB_REHYDRATED event", () => {
+ const dispatch = sinon.spy();
+ const TOP_STORIES_SECTION = {
+ id: "topstories",
+ title: "TopStories",
+ pref: { collapsed: false },
+ initialized: false,
+ rows: [{ guid: 1, link: "http://localhost", isDefault: true }],
+ topics: [],
+ read_more_endpoint: "http://localhost/read-more",
+ maxRows: 1,
+ eventSource: "TOP_STORIES",
+ };
+ wrapper = shallow(
+ <Section
+ Pocket={{ waitingForSpoc: true, pocketCta: {} }}
+ {...TOP_STORIES_SECTION}
+ dispatch={dispatch}
+ />
+ );
+ assert.notCalled(dispatch);
+
+ wrapper.setProps({ initialized: true });
+
+ assert.calledOnce(dispatch);
+ const [action] = dispatch.firstCall.args;
+ assert.equal("NEW_TAB_REHYDRATED", action.type);
+ });
+ });
+
+ describe("#numRows", () => {
+ it("should return maxRows if there is no rowsPref set", () => {
+ delete FAKE_SECTION.rowsPref;
+ wrapper = mountSectionIntlWithProps(FAKE_SECTION);
+ assert.equal(
+ wrapper.find(Section).instance().numRows,
+ FAKE_SECTION.maxRows
+ );
+ });
+
+ it("should return number of rows set in Pref if rowsPref is set", () => {
+ const numRows = 2;
+ Object.assign(FAKE_SECTION, {
+ rowsPref: "section.rows",
+ maxRows: 4,
+ Prefs: { values: { "section.rows": numRows } },
+ });
+ wrapper = mountSectionWithProps(FAKE_SECTION);
+ assert.equal(wrapper.find(Section).instance().numRows, numRows);
+ });
+
+ it("should return number of rows set in Pref even if higher than maxRows value", () => {
+ const numRows = 10;
+ Object.assign(FAKE_SECTION, {
+ rowsPref: "section.rows",
+ maxRows: 4,
+ Prefs: { values: { "section.rows": numRows } },
+ });
+ wrapper = mountSectionWithProps(FAKE_SECTION);
+ assert.equal(wrapper.find(Section).instance().numRows, numRows);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx b/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx
new file mode 100644
index 0000000000..4009909c81
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx
@@ -0,0 +1,1919 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import { MIN_RICH_FAVICON_SIZE } from "content-src/components/TopSites/TopSitesConstants";
+import {
+ TOP_SITES_DEFAULT_ROWS,
+ TOP_SITES_MAX_SITES_PER_ROW,
+} from "common/Reducers.sys.mjs";
+import {
+ TopSite,
+ TopSiteLink,
+ _TopSiteList as TopSiteList,
+ TopSitePlaceholder,
+} from "content-src/components/TopSites/TopSite";
+import {
+ INTERSECTION_RATIO,
+ TopSiteImpressionWrapper,
+} from "content-src/components/TopSites/TopSiteImpressionWrapper";
+import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton";
+import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import React from "react";
+import { mount, shallow } from "enzyme";
+import { TopSiteForm } from "content-src/components/TopSites/TopSiteForm";
+import { TopSiteFormInput } from "content-src/components/TopSites/TopSiteFormInput";
+import { _TopSites as TopSites } from "content-src/components/TopSites/TopSites";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+
+const perfSvc = {
+ mark() {},
+ getMostRecentAbsMarkStartByName() {},
+};
+
+const DEFAULT_PROPS = {
+ Prefs: { values: { featureConfig: {} } },
+ TopSites: { initialized: true, rows: [] },
+ TopSitesRows: TOP_SITES_DEFAULT_ROWS,
+ topSiteIconType: () => "no_image",
+ dispatch() {},
+ perfSvc,
+};
+
+const DEFAULT_BLOB_URL = "blob://test";
+
+describe("<TopSites>", () => {
+ let sandbox;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render a TopSites element", () => {
+ const wrapper = shallow(<TopSites {...DEFAULT_PROPS} />);
+ assert.ok(wrapper.exists());
+ });
+ describe("#_dispatchTopSitesStats", () => {
+ let globals;
+ let wrapper;
+ let dispatchStatsSpy;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox.stub(DEFAULT_PROPS, "dispatch");
+ wrapper = shallow(<TopSites {...DEFAULT_PROPS} />, {
+ disableLifecycleMethods: true,
+ });
+ dispatchStatsSpy = sandbox.spy(
+ wrapper.instance(),
+ "_dispatchTopSitesStats"
+ );
+ });
+ afterEach(() => {
+ globals.restore();
+ sandbox.restore();
+ });
+ it("should call _dispatchTopSitesStats on componentDidMount", () => {
+ wrapper.instance().componentDidMount();
+
+ assert.calledOnce(dispatchStatsSpy);
+ });
+ it("should call _dispatchTopSitesStats on componentDidUpdate", () => {
+ wrapper.instance().componentDidUpdate();
+
+ assert.calledOnce(dispatchStatsSpy);
+ });
+ it("should dispatch SAVE_SESSION_PERF_DATA", () => {
+ wrapper.instance()._dispatchTopSitesStats();
+
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 0,
+ },
+ topsites_pinned: 0,
+ topsites_search_shortcuts: 0,
+ },
+ })
+ );
+ });
+ it("should correctly count TopSite images - just screenshot", () => {
+ const rows = [{ screenshot: true }];
+ sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
+ wrapper.instance()._dispatchTopSitesStats();
+
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot: 1,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 0,
+ },
+ topsites_pinned: 0,
+ topsites_search_shortcuts: 0,
+ },
+ })
+ );
+ });
+ it("should correctly count TopSite images - custom_screenshot", () => {
+ const rows = [{ customScreenshotURL: true }];
+ sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
+ wrapper.instance()._dispatchTopSitesStats();
+
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 1,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 0,
+ },
+ topsites_pinned: 0,
+ topsites_search_shortcuts: 0,
+ },
+ })
+ );
+ });
+ it("should correctly count TopSite images - rich_icon", () => {
+ const rows = [{ faviconSize: MIN_RICH_FAVICON_SIZE }];
+ sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
+ wrapper.instance()._dispatchTopSitesStats();
+
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 1,
+ no_image: 0,
+ },
+ topsites_pinned: 0,
+ topsites_search_shortcuts: 0,
+ },
+ })
+ );
+ });
+ it("should correctly count TopSite images - tippytop", () => {
+ const rows = [
+ { tippyTopIcon: "foo" },
+ { faviconRef: "tippytop" },
+ { faviconRef: "foobar" },
+ ];
+ sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
+ wrapper.instance()._dispatchTopSitesStats();
+
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 2,
+ rich_icon: 0,
+ no_image: 1,
+ },
+ topsites_pinned: 0,
+ topsites_search_shortcuts: 0,
+ },
+ })
+ );
+ });
+ it("should correctly count TopSite images - no image", () => {
+ const rows = [{}];
+ sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
+ wrapper.instance()._dispatchTopSitesStats();
+
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 1,
+ },
+ topsites_pinned: 0,
+ topsites_search_shortcuts: 0,
+ },
+ })
+ );
+ });
+ it("should correctly count pinned Top Sites", () => {
+ const rows = [
+ { isPinned: true },
+ { isPinned: false },
+ { isPinned: true },
+ ];
+ sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
+ wrapper.instance()._dispatchTopSitesStats();
+
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 3,
+ },
+ topsites_pinned: 2,
+ topsites_search_shortcuts: 0,
+ },
+ })
+ );
+ });
+ it("should correctly count search shortcut Top Sites", () => {
+ const rows = [{ searchTopSite: true }, { searchTopSite: true }];
+ sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
+ wrapper.instance()._dispatchTopSitesStats();
+
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 2,
+ },
+ topsites_pinned: 0,
+ topsites_search_shortcuts: 2,
+ },
+ })
+ );
+ });
+ it("should only count visible top sites on wide layout", () => {
+ globals.set("matchMedia", () => ({ matches: true }));
+ const rows = [
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ ];
+ sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
+
+ wrapper.instance()._dispatchTopSitesStats();
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 8,
+ },
+ topsites_pinned: 0,
+ topsites_search_shortcuts: 0,
+ },
+ })
+ );
+ });
+ it("should only count visible top sites on normal layout", () => {
+ globals.set("matchMedia", () => ({ matches: false }));
+ const rows = [
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ {},
+ ];
+ sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
+ wrapper.instance()._dispatchTopSitesStats();
+ assert.calledOnce(DEFAULT_PROPS.dispatch);
+ assert.calledWithExactly(
+ DEFAULT_PROPS.dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 6,
+ },
+ topsites_pinned: 0,
+ topsites_search_shortcuts: 0,
+ },
+ })
+ );
+ });
+ });
+});
+
+describe("<TopSiteLink>", () => {
+ let globals;
+ let link;
+ let url;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ url = {
+ createObjectURL: globals.sandbox.stub().returns(DEFAULT_BLOB_URL),
+ revokeObjectURL: globals.sandbox.spy(),
+ };
+ globals.set("URL", url);
+ link = { url: "https://foo.com", screenshot: "foo.jpg", hostname: "foo" };
+ });
+ afterEach(() => globals.restore());
+ it("should add the right url", () => {
+ link.url = "https://www.foobar.org";
+ const wrapper = shallow(<TopSiteLink link={link} />);
+ assert.propertyVal(
+ wrapper.find("a").props(),
+ "href",
+ "https://www.foobar.org"
+ );
+ });
+ it("should not add the url to the href if it a search shortcut", () => {
+ link.searchTopSite = true;
+ const wrapper = shallow(<TopSiteLink link={link} />);
+ assert.isUndefined(wrapper.find("a").props().href);
+ });
+ it("should have rtl direction automatically set for text", () => {
+ const wrapper = shallow(<TopSiteLink link={link} />);
+
+ assert.isTrue(!!wrapper.find("[dir='auto']").length);
+ });
+ it("should render a title", () => {
+ const wrapper = shallow(<TopSiteLink link={link} title="foobar" />);
+ const titleEl = wrapper.find(".title");
+
+ assert.equal(titleEl.text(), "foobar");
+ });
+ it("should have only the title as the text of the link", () => {
+ const wrapper = shallow(<TopSiteLink link={link} title="foobar" />);
+
+ assert.equal(wrapper.find("a").text(), "foobar");
+ });
+ it("should render the pin icon for pinned links", () => {
+ link.isPinned = true;
+ link.pinnedIndex = 7;
+ const wrapper = shallow(<TopSiteLink link={link} />);
+ assert.equal(wrapper.find(".icon-pin-small").length, 1);
+ });
+ it("should not render the pin icon for non pinned links", () => {
+ link.isPinned = false;
+ const wrapper = shallow(<TopSiteLink link={link} />);
+ assert.equal(wrapper.find(".icon-pin-small").length, 0);
+ });
+ it("should render the first letter of the title as a fallback for missing icons", () => {
+ const wrapper = shallow(<TopSiteLink link={link} title={"foo"} />);
+ assert.equal(wrapper.find(".icon-wrapper").prop("data-fallback"), "f");
+ });
+ it("should render the tippy top icon if provided and not a small icon", () => {
+ link.tippyTopIcon = "foo.png";
+ link.backgroundColor = "#FFFFFF";
+ const wrapper = shallow(<TopSiteLink link={link} />);
+ assert.lengthOf(wrapper.find(".screenshot"), 0);
+ assert.lengthOf(wrapper.find(".default-icon"), 0);
+ const tippyTop = wrapper.find(".rich-icon");
+ assert.propertyVal(
+ tippyTop.props().style,
+ "backgroundImage",
+ "url(foo.png)"
+ );
+ assert.propertyVal(tippyTop.props().style, "backgroundColor", "#FFFFFF");
+ });
+ it("should render a rich icon if provided and not a small icon", () => {
+ link.favicon = "foo.png";
+ link.faviconSize = 196;
+ link.backgroundColor = "#FFFFFF";
+ const wrapper = shallow(<TopSiteLink link={link} />);
+ assert.lengthOf(wrapper.find(".screenshot"), 0);
+ assert.lengthOf(wrapper.find(".default-icon"), 0);
+ const richIcon = wrapper.find(".rich-icon");
+ assert.propertyVal(
+ richIcon.props().style,
+ "backgroundImage",
+ "url(foo.png)"
+ );
+ assert.propertyVal(richIcon.props().style, "backgroundColor", "#FFFFFF");
+ });
+ it("should not render a rich icon if it is smaller than 96x96", () => {
+ link.favicon = "foo.png";
+ link.faviconSize = 48;
+ link.backgroundColor = "#FFFFFF";
+ const wrapper = shallow(<TopSiteLink link={link} />);
+ assert.lengthOf(wrapper.find(".default-icon"), 1);
+ assert.equal(wrapper.find(".rich-icon").length, 0);
+ });
+ it("should apply just the default class name to the outer link if props.className is falsey", () => {
+ const wrapper = shallow(<TopSiteLink className={false} />);
+ assert.ok(wrapper.find("li").hasClass("top-site-outer"));
+ });
+ it("should add props.className to the outer link element", () => {
+ const wrapper = shallow(<TopSiteLink className="foo bar" />);
+ assert.ok(wrapper.find("li").hasClass("top-site-outer foo bar"));
+ });
+ describe("#_allowDrop", () => {
+ let wrapper;
+ let event;
+ beforeEach(() => {
+ event = {
+ dataTransfer: {
+ types: ["text/topsite-index"],
+ },
+ };
+ wrapper = shallow(
+ <TopSiteLink isDraggable={true} onDragEvent={() => {}} />
+ );
+ });
+ it("should be droppable for basic case", () => {
+ const result = wrapper.instance()._allowDrop(event);
+ assert.isTrue(result);
+ });
+ it("should not be droppable for sponsored_position", () => {
+ wrapper.setProps({ link: { sponsored_position: 1 } });
+ const result = wrapper.instance()._allowDrop(event);
+ assert.isFalse(result);
+ });
+ it("should not be droppable for link.type", () => {
+ wrapper.setProps({ link: { type: "SPOC" } });
+ const result = wrapper.instance()._allowDrop(event);
+ assert.isFalse(result);
+ });
+ });
+ describe("#onDragEvent", () => {
+ let simulate;
+ let wrapper;
+ beforeEach(() => {
+ wrapper = shallow(
+ <TopSiteLink isDraggable={true} onDragEvent={() => {}} />
+ );
+ simulate = type => {
+ const event = {
+ dataTransfer: { setData() {}, types: { includes() {} } },
+ preventDefault() {
+ this.prevented = true;
+ },
+ target: { blur() {} },
+ type,
+ };
+ wrapper.simulate(type, event);
+ return event;
+ };
+ });
+ it("should allow clicks without dragging", () => {
+ simulate("mousedown");
+ simulate("mouseup");
+
+ const event = simulate("click");
+
+ assert.notOk(event.prevented);
+ });
+ it("should prevent clicks after dragging", () => {
+ simulate("mousedown");
+ simulate("dragstart");
+ simulate("dragenter");
+ simulate("drop");
+ simulate("dragend");
+ simulate("mouseup");
+
+ const event = simulate("click");
+
+ assert.ok(event.prevented);
+ });
+ it("should allow clicks after dragging then clicking", () => {
+ simulate("mousedown");
+ simulate("dragstart");
+ simulate("dragenter");
+ simulate("drop");
+ simulate("dragend");
+ simulate("mouseup");
+ simulate("click");
+
+ simulate("mousedown");
+ simulate("mouseup");
+
+ const event = simulate("click");
+
+ assert.notOk(event.prevented);
+ });
+ it("should prevent dragging with sponsored_position from dragstart", () => {
+ const preventDefault = sinon.stub();
+ const blur = sinon.stub();
+ wrapper.setProps({ link: { sponsored_position: 1 } });
+ wrapper.instance().onDragEvent({
+ type: "dragstart",
+ preventDefault,
+ target: { blur },
+ });
+ assert.calledOnce(preventDefault);
+ assert.calledOnce(blur);
+ assert.isUndefined(wrapper.instance().dragged);
+ });
+ it("should prevent dragging with link.shim from dragstart", () => {
+ const preventDefault = sinon.stub();
+ const blur = sinon.stub();
+ wrapper.setProps({ link: { type: "SPOC" } });
+ wrapper.instance().onDragEvent({
+ type: "dragstart",
+ preventDefault,
+ target: { blur },
+ });
+ assert.calledOnce(preventDefault);
+ assert.calledOnce(blur);
+ assert.isUndefined(wrapper.instance().dragged);
+ });
+ });
+
+ describe("#generateColor", () => {
+ let colors;
+ beforeEach(() => {
+ colors = "#0090ED,#FF4F5F,#2AC3A2";
+ });
+
+ it("should generate a random color but always pick the same color for the same string", async () => {
+ let wrapper = shallow(
+ <TopSiteLink colors={colors} title={"food"} link={link} />
+ );
+
+ assert.equal(wrapper.find(".icon-wrapper").prop("data-fallback"), "f");
+ assert.equal(
+ wrapper.find(".icon-wrapper").prop("style").backgroundColor,
+ colors.split(",")[1]
+ );
+ assert.ok(true);
+ });
+
+ it("should generate a different random color", async () => {
+ let wrapper = shallow(
+ <TopSiteLink colors={colors} title={"fam"} link={link} />
+ );
+
+ assert.equal(
+ wrapper.find(".icon-wrapper").prop("style").backgroundColor,
+ colors.split(",")[2]
+ );
+ assert.ok(true);
+ });
+
+ it("should generate a third random color", async () => {
+ let wrapper = shallow(<TopSiteLink colors={colors} title={"foo"} />);
+
+ assert.equal(wrapper.find(".icon-wrapper").prop("data-fallback"), "f");
+ assert.equal(
+ wrapper.find(".icon-wrapper").prop("style").backgroundColor,
+ colors.split(",")[0]
+ );
+ assert.ok(true);
+ });
+ });
+});
+
+describe("<TopSite>", () => {
+ let link;
+ beforeEach(() => {
+ link = { url: "https://foo.com", screenshot: "foo.jpg", hostname: "foo" };
+ });
+
+ // Build IntersectionObserver class with the arg `entries` for the intersect callback.
+ function buildIntersectionObserver(entries) {
+ return class {
+ constructor(callback) {
+ this.callback = callback;
+ }
+
+ observe() {
+ this.callback(entries);
+ }
+
+ unobserve() {}
+ };
+ }
+
+ it("should render a TopSite", () => {
+ const wrapper = shallow(<TopSite link={link} />);
+ assert.ok(wrapper.exists());
+ });
+
+ it("should render a shortened title based off the url", () => {
+ link.url = "https://www.foobar.org";
+ link.hostname = "foobar";
+ link.eTLD = "org";
+ const wrapper = shallow(<TopSite link={link} />);
+
+ assert.equal(wrapper.find(TopSiteLink).props().title, "foobar");
+ });
+
+ it("should parse args for fluent correctly", () => {
+ const title = '"fluent"';
+ link.hostname = title;
+
+ const wrapper = mount(<TopSite link={link} />);
+ const button = wrapper.find(
+ "button[data-l10n-id='newtab-menu-content-tooltip']"
+ );
+ assert.equal(button.prop("data-l10n-args"), JSON.stringify({ title }));
+ });
+
+ it("should have .active class, on top-site-outer if context menu is open", () => {
+ const wrapper = shallow(<TopSite link={link} index={1} activeIndex={1} />);
+ wrapper.setState({ showContextMenu: true });
+
+ assert.equal(wrapper.find(TopSiteLink).props().className.trim(), "active");
+ });
+ it("should not add .active class, on top-site-outer if context menu is closed", () => {
+ const wrapper = shallow(<TopSite link={link} index={1} />);
+ wrapper.setState({ showContextMenu: false, activeTile: 1 });
+ assert.equal(wrapper.find(TopSiteLink).props().className, "");
+ });
+ it("should render a context menu button", () => {
+ const wrapper = shallow(<TopSite link={link} />);
+ assert.equal(wrapper.find(ContextMenuButton).length, 1);
+ });
+ it("should render a link menu", () => {
+ const wrapper = shallow(<TopSite link={link} />);
+ assert.equal(wrapper.find(LinkMenu).length, 1);
+ });
+ it("should pass onUpdate, site, options, and index to LinkMenu", () => {
+ const wrapper = shallow(<TopSite link={link} />);
+ const linkMenuProps = wrapper.find(LinkMenu).props();
+ ["onUpdate", "site", "index", "options"].forEach(prop =>
+ assert.property(linkMenuProps, prop)
+ );
+ });
+ it("should pass through the correct menu options to LinkMenu", () => {
+ const wrapper = shallow(<TopSite link={link} />);
+ const linkMenuProps = wrapper.find(LinkMenu).props();
+ assert.deepEqual(linkMenuProps.options, [
+ "CheckPinTopSite",
+ "EditTopSite",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ "DeleteUrl",
+ ]);
+ });
+ it("should record impressions for visible organic Top Sites", () => {
+ const dispatch = sinon.stub();
+ const wrapper = shallow(
+ <TopSite
+ link={link}
+ index={3}
+ dispatch={dispatch}
+ IntersectionObserver={buildIntersectionObserver([
+ {
+ isIntersecting: true,
+ intersectionRatio: INTERSECTION_RATIO,
+ },
+ ])}
+ document={{
+ visibilityState: "visible",
+ addEventListener: sinon.stub(),
+ removeEventListener: sinon.stub(),
+ }}
+ />
+ );
+ const linkWrapper = wrapper.find(TopSiteLink).dive();
+ assert.ok(linkWrapper.exists());
+ const impressionWrapper = linkWrapper.find(TopSiteImpressionWrapper).dive();
+ assert.ok(impressionWrapper.exists());
+
+ assert.calledOnce(dispatch);
+
+ let [action] = dispatch.firstCall.args;
+ assert.equal(action.type, at.TOP_SITES_ORGANIC_IMPRESSION_STATS);
+
+ assert.propertyVal(action.data, "type", "impression");
+ assert.propertyVal(action.data, "source", "newtab");
+ assert.propertyVal(action.data, "position", 3);
+ });
+ it("should record impressions for visible sponsored Top Sites", () => {
+ const dispatch = sinon.stub();
+ const wrapper = shallow(
+ <TopSite
+ link={Object.assign({}, link, {
+ sponsored_position: 2,
+ sponsored_tile_id: 12345,
+ sponsored_impression_url: "http://impression.example.com/",
+ })}
+ index={3}
+ dispatch={dispatch}
+ IntersectionObserver={buildIntersectionObserver([
+ {
+ isIntersecting: true,
+ intersectionRatio: INTERSECTION_RATIO,
+ },
+ ])}
+ document={{
+ visibilityState: "visible",
+ addEventListener: sinon.stub(),
+ removeEventListener: sinon.stub(),
+ }}
+ />
+ );
+ const linkWrapper = wrapper.find(TopSiteLink).dive();
+ assert.ok(linkWrapper.exists());
+ const impressionWrapper = linkWrapper.find(TopSiteImpressionWrapper).dive();
+ assert.ok(impressionWrapper.exists());
+
+ assert.calledOnce(dispatch);
+
+ let [action] = dispatch.firstCall.args;
+ assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS);
+
+ assert.propertyVal(action.data, "type", "impression");
+ assert.propertyVal(action.data, "tile_id", 12345);
+ assert.propertyVal(action.data, "source", "newtab");
+ assert.propertyVal(action.data, "position", 3);
+ assert.propertyVal(
+ action.data,
+ "reporting_url",
+ "http://impression.example.com/"
+ );
+ assert.propertyVal(action.data, "advertiser", "foo");
+ });
+
+ describe("#onLinkClick", () => {
+ it("should call dispatch when the link is clicked", () => {
+ const dispatch = sinon.stub();
+ const wrapper = shallow(
+ <TopSite link={link} index={3} dispatch={dispatch} />
+ );
+
+ wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} });
+
+ let [action] = dispatch.firstCall.args;
+ assert.isUserEventAction(action);
+
+ assert.propertyVal(action.data, "event", "CLICK");
+ assert.propertyVal(action.data, "source", "TOP_SITES");
+ assert.propertyVal(action.data, "action_position", 3);
+
+ [action] = dispatch.secondCall.args;
+ assert.propertyVal(action, "type", at.OPEN_LINK);
+
+ // Organic Top Site click event.
+ [action] = dispatch.thirdCall.args;
+ assert.equal(action.type, at.TOP_SITES_ORGANIC_IMPRESSION_STATS);
+
+ assert.propertyVal(action.data, "type", "click");
+ assert.propertyVal(action.data, "source", "newtab");
+ assert.propertyVal(action.data, "position", 3);
+ });
+ it("should dispatch a UserEventAction with the right data", () => {
+ const dispatch = sinon.stub();
+ const wrapper = shallow(
+ <TopSite
+ link={Object.assign({}, link, {
+ iconType: "rich_icon",
+ isPinned: true,
+ })}
+ index={3}
+ dispatch={dispatch}
+ />
+ );
+
+ wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} });
+
+ const [action] = dispatch.firstCall.args;
+ assert.isUserEventAction(action);
+
+ assert.propertyVal(action.data, "event", "CLICK");
+ assert.propertyVal(action.data, "source", "TOP_SITES");
+ assert.propertyVal(action.data, "action_position", 3);
+ assert.propertyVal(action.data.value, "card_type", "pinned");
+ assert.propertyVal(action.data.value, "icon_type", "rich_icon");
+ });
+ it("should dispatch a UserEventAction with the right data for search top site", () => {
+ const dispatch = sinon.stub();
+ const siteInfo = {
+ iconType: "tippytop",
+ isPinned: true,
+ searchTopSite: true,
+ hostname: "google",
+ label: "@google",
+ };
+ const wrapper = shallow(
+ <TopSite
+ link={Object.assign({}, link, siteInfo)}
+ index={3}
+ dispatch={dispatch}
+ />
+ );
+
+ wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} });
+
+ const [action] = dispatch.firstCall.args;
+ assert.isUserEventAction(action);
+
+ assert.propertyVal(action.data, "event", "CLICK");
+ assert.propertyVal(action.data, "source", "TOP_SITES");
+ assert.propertyVal(action.data, "action_position", 3);
+ assert.propertyVal(action.data.value, "card_type", "search");
+ assert.propertyVal(action.data.value, "icon_type", "tippytop");
+ assert.propertyVal(action.data.value, "search_vendor", "google");
+ });
+ it("should dispatch a UserEventAction with the right data for SPOC top site", () => {
+ const dispatch = sinon.stub();
+ const siteInfo = {
+ id: 1,
+ iconType: "custom_screenshot",
+ type: "SPOC",
+ pos: 1,
+ label: "test advertiser",
+ };
+ const wrapper = shallow(
+ <TopSite
+ link={Object.assign({}, link, siteInfo)}
+ index={0}
+ dispatch={dispatch}
+ />
+ );
+
+ wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} });
+
+ let [action] = dispatch.firstCall.args;
+ assert.isUserEventAction(action);
+
+ assert.propertyVal(action.data, "event", "CLICK");
+ assert.propertyVal(action.data, "source", "TOP_SITES");
+ assert.propertyVal(action.data, "action_position", 0);
+ assert.propertyVal(action.data.value, "card_type", "spoc");
+ assert.propertyVal(action.data.value, "icon_type", "custom_screenshot");
+
+ // Pocket SPOC click event.
+ [action] = dispatch.getCall(2).args;
+ assert.equal(action.type, at.TELEMETRY_IMPRESSION_STATS);
+
+ assert.propertyVal(action.data, "click", 0);
+ assert.propertyVal(action.data, "source", "TOP_SITES");
+
+ // Topsite SPOC click event.
+ [action] = dispatch.getCall(3).args;
+ assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS);
+
+ assert.propertyVal(action.data, "type", "click");
+ assert.propertyVal(action.data, "tile_id", 1);
+ assert.propertyVal(action.data, "source", "newtab");
+ assert.propertyVal(action.data, "position", 1);
+ assert.propertyVal(action.data, "advertiser", "test advertiser");
+ });
+ it("should dispatch OPEN_LINK with the right data", () => {
+ const dispatch = sinon.stub();
+ const wrapper = shallow(
+ <TopSite
+ link={Object.assign({}, link, { typedBonus: true })}
+ index={3}
+ dispatch={dispatch}
+ />
+ );
+
+ wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} });
+
+ const [action] = dispatch.secondCall.args;
+ assert.propertyVal(action, "type", at.OPEN_LINK);
+ assert.propertyVal(action.data, "typedBonus", true);
+ });
+ });
+});
+
+describe("<TopSiteForm>", () => {
+ let wrapper;
+ let sandbox;
+
+ function setup(props = {}) {
+ sandbox = sinon.createSandbox();
+ const customProps = Object.assign(
+ {},
+ { onClose: sandbox.spy(), dispatch: sandbox.spy() },
+ props
+ );
+ wrapper = mount(<TopSiteForm {...customProps} />);
+ }
+
+ describe("validateForm", () => {
+ beforeEach(() => setup({ site: { url: "http://foo" } }));
+
+ it("should return true for a correct URL", () => {
+ wrapper.setState({ url: "foo" });
+
+ assert.isTrue(wrapper.instance().validateForm());
+ });
+
+ it("should return false for a incorrect URL", () => {
+ wrapper.setState({ url: " " });
+
+ assert.isNull(wrapper.instance().validateForm());
+ assert.isTrue(wrapper.state().validationError);
+ });
+
+ it("should return true for a correct custom screenshot URL", () => {
+ wrapper.setState({ customScreenshotUrl: "foo" });
+
+ assert.isTrue(wrapper.instance().validateForm());
+ });
+
+ it("should return false for a incorrect custom screenshot URL", () => {
+ wrapper.setState({ customScreenshotUrl: " " });
+
+ assert.isNull(wrapper.instance().validateForm());
+ });
+
+ it("should return true for an empty custom screenshot URL", () => {
+ wrapper.setState({ customScreenshotUrl: "" });
+
+ assert.isTrue(wrapper.instance().validateForm());
+ });
+
+ it("should return false for file: protocol", () => {
+ wrapper.setState({ customScreenshotUrl: "file:///C:/Users/foo" });
+
+ assert.isFalse(wrapper.instance().validateForm());
+ });
+ });
+
+ describe("#previewButton", () => {
+ beforeEach(() =>
+ setup({
+ site: { customScreenshotURL: "http://foo.com" },
+ previewResponse: null,
+ })
+ );
+
+ it("should render the preview button on invalid urls", () => {
+ assert.equal(0, wrapper.find(".preview").length);
+
+ wrapper.setState({ customScreenshotUrl: " " });
+
+ assert.equal(1, wrapper.find(".preview").length);
+ });
+
+ it("should render the preview button when input value updated", () => {
+ assert.equal(0, wrapper.find(".preview").length);
+
+ wrapper.setState({
+ customScreenshotUrl: "http://baz.com",
+ screenshotPreview: null,
+ });
+
+ assert.equal(1, wrapper.find(".preview").length);
+ });
+ });
+
+ describe("preview request", () => {
+ beforeEach(() => {
+ setup({
+ site: { customScreenshotURL: "http://foo.com", url: "http://foo.com" },
+ previewResponse: null,
+ });
+ });
+
+ it("shouldn't dispatch a request for invalid urls", () => {
+ wrapper.setState({ customScreenshotUrl: " ", url: "foo" });
+
+ wrapper.find(".preview").simulate("click");
+
+ assert.notCalled(wrapper.props().dispatch);
+ });
+
+ it("should dispatch a PREVIEW_REQUEST", () => {
+ wrapper.setState({ customScreenshotUrl: "screenshot" });
+ wrapper.find(".preview").simulate("submit");
+
+ assert.calledTwice(wrapper.props().dispatch);
+ assert.calledWith(
+ wrapper.props().dispatch,
+ ac.AlsoToMain({
+ type: at.PREVIEW_REQUEST,
+ data: { url: "http://screenshot" },
+ })
+ );
+ assert.calledWith(
+ wrapper.props().dispatch,
+ ac.UserEvent({
+ event: "PREVIEW_REQUEST",
+ source: "TOP_SITES",
+ })
+ );
+ });
+ });
+
+ describe("#TopSiteLink", () => {
+ beforeEach(() => {
+ setup();
+ });
+
+ it("should display a TopSiteLink preview", () => {
+ assert.equal(wrapper.find(TopSiteLink).length, 1);
+ });
+
+ it("should display an icon for tippyTop sites", () => {
+ wrapper.setProps({ site: { tippyTopIcon: "bar" } });
+
+ assert.equal(
+ wrapper.find(".top-site-icon").getDOMNode().style["background-image"],
+ 'url("bar")'
+ );
+ });
+
+ it("should not display a preview screenshot", () => {
+ wrapper.setProps({ previewResponse: "foo", previewUrl: "foo" });
+
+ assert.lengthOf(wrapper.find(".screenshot"), 0);
+ });
+
+ it("should not render any icon on error", () => {
+ wrapper.setProps({ previewResponse: "" });
+
+ assert.equal(wrapper.find(".top-site-icon").length, 0);
+ });
+
+ it("should render the search icon when searchTopSite is true", () => {
+ wrapper.setProps({ site: { tippyTopIcon: "bar", searchTopSite: true } });
+
+ assert.equal(
+ wrapper.find(".rich-icon").getDOMNode().style["background-image"],
+ 'url("bar")'
+ );
+ assert.isTrue(wrapper.find(".search-topsite").exists());
+ });
+ });
+
+ describe("#addMode", () => {
+ beforeEach(() => setup());
+
+ it("should render the component", () => {
+ assert.ok(wrapper.find(TopSiteForm).exists());
+ });
+ it("should have the correct header", () => {
+ assert.equal(
+ wrapper.findWhere(
+ n =>
+ n.length &&
+ n.prop("data-l10n-id") === "newtab-topsites-add-shortcut-header"
+ ).length,
+ 1
+ );
+ });
+ it("should have the correct button text", () => {
+ assert.equal(
+ wrapper.findWhere(
+ n =>
+ n.length && n.prop("data-l10n-id") === "newtab-topsites-save-button"
+ ).length,
+ 0
+ );
+ assert.equal(
+ wrapper.findWhere(
+ n =>
+ n.length && n.prop("data-l10n-id") === "newtab-topsites-add-button"
+ ).length,
+ 1
+ );
+ });
+ it("should not render a preview button", () => {
+ assert.equal(0, wrapper.find(".custom-image-input-container").length);
+ });
+ it("should call onClose if Cancel button is clicked", () => {
+ wrapper.find(".cancel").simulate("click");
+ assert.calledOnce(wrapper.instance().props.onClose);
+ });
+ it("should set validationError if url is empty", () => {
+ assert.equal(wrapper.state().validationError, false);
+ wrapper.find(".done").simulate("submit");
+ assert.equal(wrapper.state().validationError, true);
+ });
+ it("should set validationError if url is invalid", () => {
+ wrapper.setState({ url: "not valid" });
+ assert.equal(wrapper.state().validationError, false);
+ wrapper.find(".done").simulate("submit");
+ assert.equal(wrapper.state().validationError, true);
+ });
+ it("should call onClose and dispatch with right args if URL is valid", () => {
+ wrapper.setState({ url: "valid.com", label: "a label" });
+ wrapper.find(".done").simulate("submit");
+ assert.calledOnce(wrapper.instance().props.onClose);
+ assert.calledWith(wrapper.instance().props.dispatch, {
+ data: {
+ site: { label: "a label", url: "http://valid.com" },
+ index: -1,
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: at.TOP_SITES_PIN,
+ });
+ assert.calledWith(wrapper.instance().props.dispatch, {
+ data: {
+ action_position: -1,
+ source: "TOP_SITES",
+ event: "TOP_SITES_EDIT",
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: at.TELEMETRY_USER_EVENT,
+ });
+ });
+ it("should not pass empty string label in dispatch data", () => {
+ wrapper.setState({ url: "valid.com", label: "" });
+ wrapper.find(".done").simulate("submit");
+ assert.calledWith(wrapper.instance().props.dispatch, {
+ data: { site: { url: "http://valid.com" }, index: -1 },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: at.TOP_SITES_PIN,
+ });
+ });
+ it("should open the custom screenshot input", () => {
+ assert.isFalse(wrapper.state().showCustomScreenshotForm);
+
+ wrapper.find(A11yLinkButton).simulate("click");
+
+ assert.isTrue(wrapper.state().showCustomScreenshotForm);
+ });
+ });
+
+ describe("edit existing Topsite", () => {
+ beforeEach(() =>
+ setup({
+ site: {
+ url: "https://foo.bar",
+ label: "baz",
+ customScreenshotURL: "http://foo",
+ },
+ index: 7,
+ })
+ );
+
+ it("should render the component", () => {
+ assert.ok(wrapper.find(TopSiteForm).exists());
+ });
+ it("should have the correct header", () => {
+ assert.equal(
+ wrapper.findWhere(
+ n => n.prop("data-l10n-id") === "newtab-topsites-edit-shortcut-header"
+ ).length,
+ 1
+ );
+ });
+ it("should have the correct button text", () => {
+ assert.equal(
+ wrapper.findWhere(
+ n => n.prop("data-l10n-id") === "newtab-topsites-add-button"
+ ).length,
+ 0
+ );
+ assert.equal(
+ wrapper.findWhere(
+ n => n.prop("data-l10n-id") === "newtab-topsites-save-button"
+ ).length,
+ 1
+ );
+ });
+ it("should call onClose if Cancel button is clicked", () => {
+ wrapper.find(".cancel").simulate("click");
+ assert.calledOnce(wrapper.instance().props.onClose);
+ });
+ it("should show error and not call onClose or dispatch if URL is empty", () => {
+ wrapper.setState({ url: "" });
+ assert.equal(wrapper.state().validationError, false);
+ wrapper.find(".done").simulate("submit");
+ assert.equal(wrapper.state().validationError, true);
+ assert.notCalled(wrapper.instance().props.onClose);
+ assert.notCalled(wrapper.instance().props.dispatch);
+ });
+ it("should show error and not call onClose or dispatch if URL is invalid", () => {
+ wrapper.setState({ url: "not valid" });
+ assert.equal(wrapper.state().validationError, false);
+ wrapper.find(".done").simulate("submit");
+ assert.equal(wrapper.state().validationError, true);
+ assert.notCalled(wrapper.instance().props.onClose);
+ assert.notCalled(wrapper.instance().props.dispatch);
+ });
+ it("should call onClose and dispatch with right args if URL is valid", () => {
+ wrapper.find(".done").simulate("submit");
+ assert.calledOnce(wrapper.instance().props.onClose);
+ assert.calledTwice(wrapper.instance().props.dispatch);
+ assert.calledWith(wrapper.instance().props.dispatch, {
+ data: {
+ site: {
+ label: "baz",
+ url: "https://foo.bar",
+ customScreenshotURL: "http://foo",
+ },
+ index: 7,
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: at.TOP_SITES_PIN,
+ });
+ assert.calledWith(wrapper.instance().props.dispatch, {
+ data: {
+ action_position: 7,
+ source: "TOP_SITES",
+ event: "TOP_SITES_EDIT",
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: at.TELEMETRY_USER_EVENT,
+ });
+ });
+ it("should set customScreenshotURL to null if it was removed", () => {
+ wrapper.setState({ customScreenshotUrl: "" });
+
+ wrapper.find(".done").simulate("submit");
+
+ assert.calledWith(wrapper.instance().props.dispatch, {
+ data: {
+ site: {
+ label: "baz",
+ url: "https://foo.bar",
+ customScreenshotURL: null,
+ },
+ index: 7,
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: at.TOP_SITES_PIN,
+ });
+ });
+ it("should call onClose and dispatch with right args if URL is valid (negative index)", () => {
+ wrapper.setProps({ index: -1 });
+ wrapper.find(".done").simulate("submit");
+ assert.calledOnce(wrapper.instance().props.onClose);
+ assert.calledTwice(wrapper.instance().props.dispatch);
+ assert.calledWith(wrapper.instance().props.dispatch, {
+ data: {
+ site: {
+ label: "baz",
+ url: "https://foo.bar",
+ customScreenshotURL: "http://foo",
+ },
+ index: -1,
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: at.TOP_SITES_PIN,
+ });
+ });
+ it("should not pass empty string label in dispatch data", () => {
+ wrapper.setState({ label: "" });
+ wrapper.find(".done").simulate("submit");
+ assert.calledWith(wrapper.instance().props.dispatch, {
+ data: {
+ site: { url: "https://foo.bar", customScreenshotURL: "http://foo" },
+ index: 7,
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: at.TOP_SITES_PIN,
+ });
+ });
+ it("should render the save button if custom screenshot request finished", () => {
+ wrapper.setState({
+ customScreenshotUrl: "foo",
+ screenshotPreview: "custom",
+ });
+ assert.equal(0, wrapper.find(".preview").length);
+ assert.equal(1, wrapper.find(".done").length);
+ });
+ it("should render the save button if custom screenshot url was cleared", () => {
+ wrapper.setState({ customScreenshotUrl: "" });
+ wrapper.setProps({ site: { customScreenshotURL: "foo" } });
+ assert.equal(0, wrapper.find(".preview").length);
+ assert.equal(1, wrapper.find(".done").length);
+ });
+ });
+
+ describe("#previewMode", () => {
+ beforeEach(() => setup({ previewResponse: null }));
+
+ it("should transition from save to preview", () => {
+ wrapper.setProps({
+ site: { url: "https://foo.bar", customScreenshotURL: "baz" },
+ index: 7,
+ });
+
+ assert.equal(
+ wrapper.findWhere(
+ n =>
+ n.length && n.prop("data-l10n-id") === "newtab-topsites-save-button"
+ ).length,
+ 1
+ );
+
+ wrapper.setState({ customScreenshotUrl: "foo" });
+
+ assert.equal(
+ wrapper.findWhere(
+ n =>
+ n.length &&
+ n.prop("data-l10n-id") === "newtab-topsites-preview-button"
+ ).length,
+ 1
+ );
+ });
+
+ it("should transition from add to preview", () => {
+ assert.equal(
+ wrapper.findWhere(
+ n =>
+ n.length && n.prop("data-l10n-id") === "newtab-topsites-add-button"
+ ).length,
+ 1
+ );
+
+ wrapper.setState({ customScreenshotUrl: "foo" });
+
+ assert.equal(
+ wrapper.findWhere(
+ n =>
+ n.length &&
+ n.prop("data-l10n-id") === "newtab-topsites-preview-button"
+ ).length,
+ 1
+ );
+ });
+ });
+
+ describe("#validateUrl", () => {
+ it("should properly validate URLs", () => {
+ setup();
+ assert.ok(wrapper.instance().validateUrl("mozilla.org"));
+ assert.ok(wrapper.instance().validateUrl("https://mozilla.org"));
+ assert.ok(wrapper.instance().validateUrl("http://mozilla.org"));
+ assert.ok(
+ wrapper
+ .instance()
+ .validateUrl(
+ "https://mozilla.invisionapp.com/d/main/#/projects/prototypes"
+ )
+ );
+ assert.ok(wrapper.instance().validateUrl("httpfoobar"));
+ assert.ok(wrapper.instance().validateUrl("httpsfoo.bar"));
+ assert.isNull(wrapper.instance().validateUrl("mozilla org"));
+ assert.isNull(wrapper.instance().validateUrl(""));
+ });
+ });
+
+ describe("#cleanUrl", () => {
+ it("should properly prepend http:// to URLs when required", () => {
+ setup();
+ assert.equal(
+ "http://mozilla.org",
+ wrapper.instance().cleanUrl("mozilla.org")
+ );
+ assert.equal(
+ "http://https.org",
+ wrapper.instance().cleanUrl("https.org")
+ );
+ assert.equal("http://httpcom", wrapper.instance().cleanUrl("httpcom"));
+ assert.equal(
+ "http://mozilla.org",
+ wrapper.instance().cleanUrl("http://mozilla.org")
+ );
+ assert.equal(
+ "https://firefox.com",
+ wrapper.instance().cleanUrl("https://firefox.com")
+ );
+ });
+ });
+});
+
+describe("<TopSiteList>", () => {
+ const APP = { isForStartupCache: false };
+
+ it("should render a TopSiteList element", () => {
+ const wrapper = shallow(<TopSiteList {...DEFAULT_PROPS} App={{ APP }} />);
+ assert.ok(wrapper.exists());
+ });
+ it("should render a TopSite for each link with the right url", () => {
+ const rows = [{ url: "https://foo.com" }, { url: "https://bar.com" }];
+ const wrapper = shallow(
+ <TopSiteList {...DEFAULT_PROPS} TopSites={{ rows }} App={{ APP }} />
+ );
+ const links = wrapper.find(TopSite);
+ assert.lengthOf(links, 2);
+ rows.forEach((row, i) =>
+ assert.equal(links.get(i).props.link.url, row.url)
+ );
+ });
+ it("should slice the TopSite rows to the TopSitesRows pref", () => {
+ const rows = [];
+ for (
+ let i = 0;
+ i < TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW + 3;
+ i++
+ ) {
+ rows.push({ url: `https://foo${i}.com` });
+ }
+ const wrapper = shallow(
+ <TopSiteList
+ {...DEFAULT_PROPS}
+ TopSites={{ rows }}
+ TopSitesRows={TOP_SITES_DEFAULT_ROWS}
+ App={{ APP }}
+ />
+ );
+ const links = wrapper.find(TopSite);
+ assert.lengthOf(
+ links,
+ TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW
+ );
+ });
+ it("should fill with placeholders if TopSites rows is less than TopSitesRows", () => {
+ const rows = [{ url: "https://foo.com" }, { url: "https://bar.com" }];
+ const wrapper = shallow(
+ <TopSiteList
+ {...DEFAULT_PROPS}
+ TopSites={{ rows }}
+ TopSitesRows={1}
+ App={{ APP }}
+ />
+ );
+ assert.lengthOf(wrapper.find(TopSite), 2, "topSites");
+ assert.lengthOf(
+ wrapper.find(TopSitePlaceholder),
+ TOP_SITES_MAX_SITES_PER_ROW - 2,
+ "placeholders"
+ );
+ });
+ it("should fill sponsored top sites with placeholders while rendering for startup cache", () => {
+ const rows = [
+ { url: "https://sponsored01.com", sponsored_position: 1 },
+ { url: "https://sponsored02.com", sponsored_position: 2 },
+ { url: "https://sponsored03.com", type: "SPOC" },
+ { url: "https://foo.com" },
+ { url: "https://bar.com" },
+ ];
+ const wrapper = shallow(
+ <TopSiteList
+ {...DEFAULT_PROPS}
+ TopSites={{ rows }}
+ TopSitesRows={1}
+ App={{ isForStartupCache: true }}
+ />
+ );
+ assert.lengthOf(wrapper.find(TopSite), 2, "topSites");
+ assert.lengthOf(
+ wrapper.find(TopSitePlaceholder),
+ TOP_SITES_MAX_SITES_PER_ROW - 2,
+ "placeholders"
+ );
+ });
+ it("should fill any holes in TopSites with placeholders", () => {
+ const rows = [{ url: "https://foo.com" }];
+ rows[3] = { url: "https://bar.com" };
+ const wrapper = shallow(
+ <TopSiteList
+ {...DEFAULT_PROPS}
+ TopSites={{ rows }}
+ TopSitesRows={1}
+ App={{ APP }}
+ />
+ );
+ assert.lengthOf(wrapper.find(TopSite), 2, "topSites");
+ assert.lengthOf(
+ wrapper.find(TopSitePlaceholder),
+ TOP_SITES_MAX_SITES_PER_ROW - 2,
+ "placeholders"
+ );
+ });
+ it("should update state onDragStart and clear it onDragEnd", () => {
+ const wrapper = shallow(<TopSiteList {...DEFAULT_PROPS} App={{ APP }} />);
+ const instance = wrapper.instance();
+ const index = 7;
+ const link = { url: "https://foo.com" };
+ const title = "foo";
+ instance.onDragEvent({ type: "dragstart" }, index, link, title);
+ assert.equal(instance.state.draggedIndex, index);
+ assert.equal(instance.state.draggedSite, link);
+ assert.equal(instance.state.draggedTitle, title);
+ instance.onDragEvent({ type: "dragend" });
+ assert.deepEqual(instance.state, TopSiteList.DEFAULT_STATE);
+ });
+ it("should clear state when new props arrive after a drop", () => {
+ const site1 = { url: "https://foo.com" };
+ const site2 = { url: "https://bar.com" };
+ const rows = [site1, site2];
+ const wrapper = shallow(
+ <TopSiteList {...DEFAULT_PROPS} TopSites={{ rows }} App={{ APP }} />
+ );
+ const instance = wrapper.instance();
+ instance.setState({
+ draggedIndex: 1,
+ draggedSite: site2,
+ draggedTitle: "bar",
+ topSitesPreview: [],
+ });
+ wrapper.setProps({ TopSites: { rows: [site2, site1] } });
+ assert.deepEqual(instance.state, TopSiteList.DEFAULT_STATE);
+ });
+ it("should dispatch events on drop", () => {
+ const dispatch = sinon.spy();
+ const wrapper = shallow(
+ <TopSiteList {...DEFAULT_PROPS} dispatch={dispatch} App={{ APP }} />
+ );
+ const instance = wrapper.instance();
+ const index = 7;
+ const link = { url: "https://foo.com", customScreenshotURL: "foo" };
+ const title = "foo";
+ instance.onDragEvent({ type: "dragstart" }, index, link, title);
+ dispatch.resetHistory();
+ instance.onDragEvent({ type: "drop" }, 3);
+ assert.calledTwice(dispatch);
+ assert.calledWith(dispatch, {
+ data: {
+ draggedFromIndex: 7,
+ index: 3,
+ site: {
+ label: "foo",
+ url: "https://foo.com",
+ customScreenshotURL: "foo",
+ },
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "TOP_SITES_INSERT",
+ });
+ assert.calledWith(dispatch, {
+ data: { action_position: 3, event: "DROP", source: "TOP_SITES" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "TELEMETRY_USER_EVENT",
+ });
+ });
+ it("should make a topSitesPreview onDragEnter", () => {
+ const wrapper = shallow(<TopSiteList {...DEFAULT_PROPS} App={{ APP }} />);
+ const instance = wrapper.instance();
+ const site = { url: "https://foo.com" };
+ instance.setState({
+ draggedIndex: 4,
+ draggedSite: site,
+ draggedTitle: "foo",
+ });
+ const draggedSite = Object.assign({}, site, {
+ isPinned: true,
+ isDragged: true,
+ });
+ instance.onDragEvent({ type: "dragenter" }, 2);
+ assert.ok(instance.state.topSitesPreview);
+ assert.deepEqual(instance.state.topSitesPreview[2], draggedSite);
+ });
+ it("should _makeTopSitesPreview correctly", () => {
+ const site1 = { url: "https://foo.com" };
+ const site2 = { url: "https://bar.com" };
+ const site3 = { url: "https://baz.com" };
+ const rows = [site1, site2, site3];
+ let wrapper = shallow(
+ <TopSiteList
+ {...DEFAULT_PROPS}
+ TopSites={{ rows }}
+ TopSitesRows={1}
+ App={{ APP }}
+ />
+ );
+ let instance = wrapper.instance();
+ instance.setState({
+ draggedIndex: 0,
+ draggedSite: site1,
+ draggedTitle: "foo",
+ });
+ let draggedSite = Object.assign({}, site1, {
+ isPinned: true,
+ isDragged: true,
+ });
+ assert.deepEqual(instance._makeTopSitesPreview(1), [
+ site2,
+ draggedSite,
+ site3,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ assert.deepEqual(instance._makeTopSitesPreview(2), [
+ site2,
+ site3,
+ draggedSite,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ assert.deepEqual(instance._makeTopSitesPreview(3), [
+ site2,
+ site3,
+ null,
+ draggedSite,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ site2.isPinned = true;
+ assert.deepEqual(instance._makeTopSitesPreview(1), [
+ site2,
+ draggedSite,
+ site3,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ assert.deepEqual(instance._makeTopSitesPreview(2), [
+ site3,
+ site2,
+ draggedSite,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ site3.isPinned = true;
+ assert.deepEqual(instance._makeTopSitesPreview(1), [
+ site2,
+ draggedSite,
+ site3,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ assert.deepEqual(instance._makeTopSitesPreview(2), [
+ site2,
+ site3,
+ draggedSite,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ site2.isPinned = false;
+ assert.deepEqual(instance._makeTopSitesPreview(1), [
+ site2,
+ draggedSite,
+ site3,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ assert.deepEqual(instance._makeTopSitesPreview(2), [
+ site2,
+ site3,
+ draggedSite,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ site3.isPinned = false;
+ instance.setState({
+ draggedIndex: 1,
+ draggedSite: site2,
+ draggedTitle: "bar",
+ });
+ draggedSite = Object.assign({}, site2, { isPinned: true, isDragged: true });
+ assert.deepEqual(instance._makeTopSitesPreview(0), [
+ draggedSite,
+ site1,
+ site3,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ assert.deepEqual(instance._makeTopSitesPreview(2), [
+ site1,
+ site3,
+ draggedSite,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ site2.type = "SPOC";
+ instance.setState({
+ draggedIndex: 2,
+ draggedSite: site3,
+ draggedTitle: "baz",
+ });
+ draggedSite = Object.assign({}, site3, { isPinned: true, isDragged: true });
+ assert.deepEqual(instance._makeTopSitesPreview(0), [
+ draggedSite,
+ site2,
+ site1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ site2.type = "";
+ site2.sponsored_position = 2;
+ instance.setState({
+ draggedIndex: 2,
+ draggedSite: site3,
+ draggedTitle: "baz",
+ });
+ draggedSite = Object.assign({}, site3, { isPinned: true, isDragged: true });
+ assert.deepEqual(instance._makeTopSitesPreview(0), [
+ draggedSite,
+ site2,
+ site1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ });
+ it("should add a className hide-for-narrow to sites after 6/row", () => {
+ const rows = [];
+ for (let i = 0; i < TOP_SITES_MAX_SITES_PER_ROW; i++) {
+ rows.push({ url: `https://foo${i}.com` });
+ }
+ const wrapper = mount(
+ <TopSiteList
+ {...DEFAULT_PROPS}
+ TopSites={{ rows }}
+ TopSitesRows={1}
+ App={{ APP }}
+ />
+ );
+ assert.lengthOf(wrapper.find("li.hide-for-narrow"), 2);
+ });
+});
+
+describe("TopSitePlaceholder", () => {
+ it("should dispatch a TOP_SITES_EDIT action when edit-button is clicked", () => {
+ const dispatch = sinon.spy();
+ const wrapper = shallow(
+ <TopSitePlaceholder dispatch={dispatch} index={7} />
+ );
+
+ wrapper.find(".edit-button").first().simulate("click");
+
+ assert.calledOnce(dispatch);
+ assert.calledWithExactly(dispatch, {
+ type: at.TOP_SITES_EDIT,
+ data: { index: 7 },
+ });
+ });
+});
+
+describe("#TopSiteFormInput", () => {
+ let wrapper;
+ let onChangeStub;
+
+ describe("no errors", () => {
+ beforeEach(() => {
+ onChangeStub = sinon.stub();
+
+ wrapper = mount(
+ <TopSiteFormInput
+ titleId="newtab-topsites-title-label"
+ placeholderId="newtab-topsites-title-input"
+ errorMessageId="newtab-topsites-url-validation"
+ onChange={onChangeStub}
+ value="foo"
+ />
+ );
+ });
+
+ it("should render the provided title", () => {
+ const title = wrapper.find("span");
+ assert.propertyVal(
+ title.props(),
+ "data-l10n-id",
+ "newtab-topsites-title-label"
+ );
+ });
+
+ it("should render the provided value", () => {
+ const input = wrapper.find("input");
+
+ assert.equal(input.getDOMNode().value, "foo");
+ });
+
+ it("should render the clear button if cb is provided", () => {
+ assert.equal(wrapper.find(".icon-clear-input").length, 0);
+
+ wrapper.setProps({ onClear: sinon.stub() });
+
+ assert.equal(wrapper.find(".icon-clear-input").length, 1);
+ });
+
+ it("should show the loading indicator", () => {
+ assert.equal(wrapper.find(".loading-container").length, 0);
+
+ wrapper.setProps({ loading: true });
+
+ assert.equal(wrapper.find(".loading-container").length, 1);
+ });
+ it("should disable the input when loading indicator is present", () => {
+ assert.isFalse(wrapper.find("input").getDOMNode().disabled);
+
+ wrapper.setProps({ loading: true });
+
+ assert.isTrue(wrapper.find("input").getDOMNode().disabled);
+ });
+ });
+
+ describe("with error", () => {
+ beforeEach(() => {
+ onChangeStub = sinon.stub();
+
+ wrapper = mount(
+ <TopSiteFormInput
+ titleId="newtab-topsites-title-label"
+ placeholderId="newtab-topsites-title-input"
+ onChange={onChangeStub}
+ validationError={true}
+ errorMessageId="newtab-topsites-url-validation"
+ value="foo"
+ />
+ );
+ });
+
+ it("should render the error message", () => {
+ assert.equal(
+ wrapper.findWhere(
+ n => n.prop("data-l10n-id") === "newtab-topsites-url-validation"
+ ).length,
+ 1
+ );
+ });
+
+ it("should reset the error state on value change", () => {
+ wrapper.find("input").simulate("change", { target: { value: "bar" } });
+
+ assert.isFalse(wrapper.state().validationError);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/TopSites/SearchShortcutsForm.test.jsx b/browser/components/newtab/test/unit/content-src/components/TopSites/SearchShortcutsForm.test.jsx
new file mode 100644
index 0000000000..22c4e8192a
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/TopSites/SearchShortcutsForm.test.jsx
@@ -0,0 +1,56 @@
+import {
+ SearchShortcutsForm,
+ SelectableSearchShortcut,
+} from "content-src/components/TopSites/SearchShortcutsForm";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<SearchShortcutsForm>", () => {
+ let wrapper;
+ let sandbox;
+ let dispatchStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ dispatchStub = sandbox.stub();
+ const defaultProps = { rows: [], searchShortcuts: [] };
+ wrapper = shallow(
+ <SearchShortcutsForm TopSites={defaultProps} dispatch={dispatchStub} />
+ );
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should render", () => {
+ assert.ok(wrapper.exists());
+ assert.ok(wrapper.find(".topsite-form").exists());
+ });
+
+ it("should render SelectableSearchShortcut components", () => {
+ wrapper.setState({ shortcuts: [{}, {}] });
+
+ assert.lengthOf(
+ wrapper.find(".search-shortcuts-container div").children(),
+ 2
+ );
+ assert.equal(
+ wrapper.find(".search-shortcuts-container div").children().at(0).type(),
+ SelectableSearchShortcut
+ );
+ });
+
+ it("should render SelectableSearchShortcut components", () => {
+ const onCloseStub = sandbox.stub();
+ const fakeEvent = { preventDefault: sandbox.stub() };
+ wrapper.setState({ shortcuts: [{}, {}] });
+ wrapper.setProps({ onClose: onCloseStub });
+
+ wrapper.find(".done").simulate("click", fakeEvent);
+
+ assert.calledOnce(dispatchStub);
+ assert.calledOnce(fakeEvent.preventDefault);
+ assert.calledOnce(onCloseStub);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx b/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx
new file mode 100644
index 0000000000..79cb6ec7c5
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx
@@ -0,0 +1,150 @@
+"use strict";
+
+import {
+ TopSiteImpressionWrapper,
+ INTERSECTION_RATIO,
+} from "content-src/components/TopSites/TopSiteImpressionWrapper";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<TopSiteImpressionWrapper>", () => {
+ const FullIntersectEntries = [
+ { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO },
+ ];
+ const ZeroIntersectEntries = [
+ { isIntersecting: false, intersectionRatio: 0 },
+ ];
+ const PartialIntersectEntries = [
+ { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO / 2 },
+ ];
+
+ // Build IntersectionObserver class with the arg `entries` for the intersect callback.
+ function buildIntersectionObserver(entries) {
+ return class {
+ constructor(callback) {
+ this.callback = callback;
+ }
+
+ observe() {
+ this.callback(entries);
+ }
+
+ unobserve() {}
+ };
+ }
+
+ const DEFAULT_PROPS = {
+ actionType: at.TOP_SITES_SPONSORED_IMPRESSION_STATS,
+ tile: {
+ tile_id: 1,
+ position: 1,
+ reporting_url: "https://test.reporting.com",
+ advertiser: "test_advertiser",
+ },
+ IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
+ document: {
+ visibilityState: "visible",
+ addEventListener: sinon.stub(),
+ removeEventListener: sinon.stub(),
+ },
+ };
+
+ const InnerEl = () => <div>Inner Element</div>;
+
+ function renderTopSiteImpressionWrapper(props = {}) {
+ return shallow(
+ <TopSiteImpressionWrapper {...DEFAULT_PROPS} {...props}>
+ <InnerEl />
+ </TopSiteImpressionWrapper>
+ );
+ }
+
+ it("should render props.children", () => {
+ const wrapper = renderTopSiteImpressionWrapper();
+ assert.ok(wrapper.contains(<InnerEl />));
+ });
+ it("should not send impression when the wrapped item is visbible but below the ratio", () => {
+ const dispatch = sinon.spy();
+ const props = {
+ dispatch,
+ IntersectionObserver: buildIntersectionObserver(PartialIntersectEntries),
+ };
+ renderTopSiteImpressionWrapper(props);
+
+ assert.notCalled(dispatch);
+ });
+ it("should send an impression when the page is visible and the wrapped item meets the visibility ratio", () => {
+ const dispatch = sinon.spy();
+ const props = {
+ dispatch,
+ IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
+ };
+ renderTopSiteImpressionWrapper(props);
+
+ assert.calledOnce(dispatch);
+
+ let [action] = dispatch.firstCall.args;
+ assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS);
+ assert.deepEqual(action.data, {
+ type: "impression",
+ ...DEFAULT_PROPS.tile,
+ });
+ });
+ it("should send an impression when the wrapped item transiting from invisible to visible", () => {
+ const dispatch = sinon.spy();
+ const props = {
+ dispatch,
+ IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries),
+ };
+ const wrapper = renderTopSiteImpressionWrapper(props);
+
+ assert.notCalled(dispatch);
+
+ dispatch.resetHistory();
+ wrapper.instance().impressionObserver.callback(FullIntersectEntries);
+
+ // For the impression
+ assert.calledOnce(dispatch);
+
+ const [action] = dispatch.firstCall.args;
+ assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS);
+ assert.deepEqual(action.data, {
+ type: "impression",
+ ...DEFAULT_PROPS.tile,
+ });
+ });
+ it("should remove visibility change listener when the wrapper is removed", () => {
+ const props = {
+ dispatch: sinon.spy(),
+ document: {
+ visibilityState: "hidden",
+ addEventListener: sinon.spy(),
+ removeEventListener: sinon.spy(),
+ },
+ IntersectionObserver,
+ };
+
+ const wrapper = renderTopSiteImpressionWrapper(props);
+ assert.calledWith(props.document.addEventListener, "visibilitychange");
+ const [, listener] = props.document.addEventListener.firstCall.args;
+
+ wrapper.unmount();
+ assert.calledWith(
+ props.document.removeEventListener,
+ "visibilitychange",
+ listener
+ );
+ });
+ it("should unobserve the intersection observer when the wrapper is removed", () => {
+ const IntersectionObserver =
+ buildIntersectionObserver(ZeroIntersectEntries);
+ const spy = sinon.spy(IntersectionObserver.prototype, "unobserve");
+ const props = { dispatch: sinon.spy(), IntersectionObserver };
+
+ const wrapper = renderTopSiteImpressionWrapper(props);
+ wrapper.unmount();
+
+ assert.calledOnce(spy);
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/Topics.test.jsx b/browser/components/newtab/test/unit/content-src/components/Topics.test.jsx
new file mode 100644
index 0000000000..91d15c5d4e
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/Topics.test.jsx
@@ -0,0 +1,22 @@
+import { Topic, Topics } from "content-src/components/Topics/Topics";
+import React from "react";
+import { shallow } from "enzyme";
+
+describe("<Topics>", () => {
+ it("should render a Topics element", () => {
+ const wrapper = shallow(<Topics topics={[]} />);
+ assert.ok(wrapper.exists());
+ });
+ it("should render a Topic element for each topic with the right url", () => {
+ const data = [
+ { name: "topic1", url: "https://topic1.com" },
+ { name: "topic2", url: "https://topic2.com" },
+ ];
+
+ const wrapper = shallow(<Topics topics={data} />);
+
+ const topics = wrapper.find(Topic);
+ assert.lengthOf(topics, 2);
+ topics.forEach((topic, i) => assert.equal(topic.props().url, data[i].url));
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/components/addUtmParams.test.js b/browser/components/newtab/test/unit/content-src/components/addUtmParams.test.js
new file mode 100644
index 0000000000..953fc60d79
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/components/addUtmParams.test.js
@@ -0,0 +1,35 @@
+import {
+ addUtmParams,
+ BASE_PARAMS,
+} from "content-src/asrouter/templates/FirstRun/addUtmParams";
+
+describe("addUtmParams", () => {
+ it("should convert a string URL", () => {
+ const result = addUtmParams("https://foo.com", "foo");
+ assert.equal(result.hostname, "foo.com");
+ });
+ it("should add all base params", () => {
+ assert.match(
+ addUtmParams(new URL("https://foo.com"), "foo").toString(),
+ /utm_source=activity-stream&utm_campaign=firstrun&utm_medium=referral/
+ );
+ });
+ it("should allow updating base params utm values", () => {
+ BASE_PARAMS.utm_campaign = "firstrun-default";
+ assert.match(
+ addUtmParams(new URL("https://foo.com"), "foo", "default").toString(),
+ /utm_source=activity-stream&utm_campaign=firstrun-default&utm_medium=referral/
+ );
+ });
+ it("should add utm_term", () => {
+ const params = addUtmParams(new URL("https://foo.com"), "foo").searchParams;
+ assert.equal(params.get("utm_term"), "foo", "utm_term");
+ });
+ it("should not override the URL's existing utm param values", () => {
+ const url = new URL("https://foo.com/?utm_source=foo&utm_campaign=bar");
+ const params = addUtmParams(url, "foo").searchParams;
+ assert.equal(params.get("utm_source"), "foo", "utm_source");
+ assert.equal(params.get("utm_campaign"), "bar", "utm_campaign");
+ assert.equal(params.get("utm_medium"), "referral", "utm_medium");
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js b/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js
new file mode 100644
index 0000000000..5a7fad7cc0
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js
@@ -0,0 +1,120 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { DetectUserSessionStart } from "content-src/lib/detect-user-session-start";
+
+describe("detectUserSessionStart", () => {
+ let store;
+ class PerfService {
+ getMostRecentAbsMarkStartByName() {
+ return 1234;
+ }
+ mark() {}
+ }
+
+ beforeEach(() => {
+ store = { dispatch: () => {} };
+ });
+ describe("#sendEventOrAddListener", () => {
+ it("should call ._sendEvent immediately if the document is visible", () => {
+ const mockDocument = { visibilityState: "visible" };
+ const instance = new DetectUserSessionStart(store, {
+ document: mockDocument,
+ });
+ sinon.stub(instance, "_sendEvent");
+
+ instance.sendEventOrAddListener();
+
+ assert.calledOnce(instance._sendEvent);
+ });
+ it("should add an event listener on visibility changes the document is not visible", () => {
+ const mockDocument = {
+ visibilityState: "hidden",
+ addEventListener: sinon.spy(),
+ };
+ const instance = new DetectUserSessionStart(store, {
+ document: mockDocument,
+ });
+ sinon.stub(instance, "_sendEvent");
+
+ instance.sendEventOrAddListener();
+
+ assert.notCalled(instance._sendEvent);
+ assert.calledWith(
+ mockDocument.addEventListener,
+ "visibilitychange",
+ instance._onVisibilityChange
+ );
+ });
+ });
+ describe("#_sendEvent", () => {
+ it("should dispatch an action with the SAVE_SESSION_PERF_DATA", () => {
+ const dispatch = sinon.spy(store, "dispatch");
+ const instance = new DetectUserSessionStart(store);
+
+ instance._sendEvent();
+
+ assert.calledWith(
+ dispatch,
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: { visibility_event_rcvd_ts: sinon.match.number },
+ })
+ );
+ });
+
+ it("shouldn't send a message if getMostRecentAbsMarkStartByName throws", () => {
+ let perfService = new PerfService();
+ sinon.stub(perfService, "getMostRecentAbsMarkStartByName").throws();
+ const dispatch = sinon.spy(store, "dispatch");
+ const instance = new DetectUserSessionStart(store, { perfService });
+
+ instance._sendEvent();
+
+ assert.notCalled(dispatch);
+ });
+
+ it('should call perfService.mark("visibility_event_rcvd_ts")', () => {
+ let perfService = new PerfService();
+ sinon.stub(perfService, "mark");
+ const instance = new DetectUserSessionStart(store, { perfService });
+
+ instance._sendEvent();
+
+ assert.calledWith(perfService.mark, "visibility_event_rcvd_ts");
+ });
+ });
+
+ describe("_onVisibilityChange", () => {
+ it("should not send an event if visiblity is not visible", () => {
+ const instance = new DetectUserSessionStart(store, {
+ document: { visibilityState: "hidden" },
+ });
+ sinon.stub(instance, "_sendEvent");
+
+ instance._onVisibilityChange();
+
+ assert.notCalled(instance._sendEvent);
+ });
+ it("should send an event and remove the event listener if visibility is visible", () => {
+ const mockDocument = {
+ visibilityState: "visible",
+ removeEventListener: sinon.spy(),
+ };
+ const instance = new DetectUserSessionStart(store, {
+ document: mockDocument,
+ });
+ sinon.stub(instance, "_sendEvent");
+
+ instance._onVisibilityChange();
+
+ assert.calledOnce(instance._sendEvent);
+ assert.calledWith(
+ mockDocument.removeEventListener,
+ "visibilitychange",
+ instance._onVisibilityChange
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/lib/init-store.test.js b/browser/components/newtab/test/unit/content-src/lib/init-store.test.js
new file mode 100644
index 0000000000..5ce92d2192
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/lib/init-store.test.js
@@ -0,0 +1,207 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { addNumberReducer, GlobalOverrider } from "test/unit/utils";
+import {
+ EARLY_QUEUED_ACTIONS,
+ INCOMING_MESSAGE_NAME,
+ initStore,
+ MERGE_STORE_ACTION,
+ OUTGOING_MESSAGE_NAME,
+ queueEarlyMessageMiddleware,
+ rehydrationMiddleware,
+} from "content-src/lib/init-store";
+
+describe("initStore", () => {
+ let globals;
+ let store;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ globals.set("RPMSendAsyncMessage", globals.sandbox.spy());
+ globals.set("RPMAddMessageListener", globals.sandbox.spy());
+ store = initStore({ number: addNumberReducer });
+ });
+ afterEach(() => globals.restore());
+ it("should create a store with the provided reducers", () => {
+ assert.ok(store);
+ assert.property(store.getState(), "number");
+ });
+ it("should add a listener that dispatches actions", () => {
+ assert.calledWith(global.RPMAddMessageListener, INCOMING_MESSAGE_NAME);
+ const [, listener] = global.RPMAddMessageListener.firstCall.args;
+ globals.sandbox.spy(store, "dispatch");
+ const message = { name: INCOMING_MESSAGE_NAME, data: { type: "FOO" } };
+
+ listener(message);
+
+ assert.calledWith(store.dispatch, message.data);
+ });
+ it("should not throw if RPMAddMessageListener is not defined", () => {
+ // Note: this is being set/restored by GlobalOverrider
+ delete global.RPMAddMessageListener;
+
+ assert.doesNotThrow(() => initStore({ number: addNumberReducer }));
+ });
+ it("should log errors from failed messages", () => {
+ const [, callback] = global.RPMAddMessageListener.firstCall.args;
+ globals.sandbox.stub(global.console, "error");
+ globals.sandbox.stub(store, "dispatch").throws(Error("failed"));
+
+ const message = {
+ name: INCOMING_MESSAGE_NAME,
+ data: { type: MERGE_STORE_ACTION },
+ };
+ callback(message);
+
+ assert.calledOnce(global.console.error);
+ });
+ it("should replace the state if a MERGE_STORE_ACTION is dispatched", () => {
+ store.dispatch({ type: MERGE_STORE_ACTION, data: { number: 42 } });
+ assert.deepEqual(store.getState(), { number: 42 });
+ });
+ it("should call .send and update the local store if an AlsoToMain action is dispatched", () => {
+ const subscriber = sinon.spy();
+ const action = ac.AlsoToMain({ type: "FOO" });
+
+ store.subscribe(subscriber);
+ store.dispatch(action);
+
+ assert.calledWith(
+ global.RPMSendAsyncMessage,
+ OUTGOING_MESSAGE_NAME,
+ action
+ );
+ assert.calledOnce(subscriber);
+ });
+ it("should call .send but not update the local store if an OnlyToMain action is dispatched", () => {
+ const subscriber = sinon.spy();
+ const action = ac.OnlyToMain({ type: "FOO" });
+
+ store.subscribe(subscriber);
+ store.dispatch(action);
+
+ assert.calledWith(
+ global.RPMSendAsyncMessage,
+ OUTGOING_MESSAGE_NAME,
+ action
+ );
+ assert.notCalled(subscriber);
+ });
+ it("should not send out other types of actions", () => {
+ store.dispatch({ type: "FOO" });
+ assert.notCalled(global.RPMSendAsyncMessage);
+ });
+ describe("rehydrationMiddleware", () => {
+ it("should allow NEW_TAB_STATE_REQUEST to go through", () => {
+ const action = ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST });
+ const next = sinon.spy();
+ rehydrationMiddleware(store)(next)(action);
+ assert.calledWith(next, action);
+ });
+ it("should dispatch an additional NEW_TAB_STATE_REQUEST if INIT was received after a request", () => {
+ const requestAction = ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST });
+ const next = sinon.spy();
+ const dispatch = rehydrationMiddleware(store)(next);
+
+ dispatch(requestAction);
+ next.resetHistory();
+ dispatch({ type: at.INIT });
+
+ assert.calledWith(next, requestAction);
+ });
+ it("should allow MERGE_STORE_ACTION to go through", () => {
+ const action = { type: MERGE_STORE_ACTION };
+ const next = sinon.spy();
+ rehydrationMiddleware(store)(next)(action);
+ assert.calledWith(next, action);
+ });
+ it("should not allow actions from main to go through before MERGE_STORE_ACTION was received", () => {
+ const next = sinon.spy();
+ const dispatch = rehydrationMiddleware(store)(next);
+
+ dispatch(ac.BroadcastToContent({ type: "FOO" }));
+ dispatch(ac.AlsoToOneContent({ type: "FOO" }, 123));
+
+ assert.notCalled(next);
+ });
+ it("should allow all local actions to go through", () => {
+ const action = { type: "FOO" };
+ const next = sinon.spy();
+ rehydrationMiddleware(store)(next)(action);
+ assert.calledWith(next, action);
+ });
+ it("should allow actions from main to go through after MERGE_STORE_ACTION has been received", () => {
+ const next = sinon.spy();
+ const dispatch = rehydrationMiddleware(store)(next);
+
+ dispatch({ type: MERGE_STORE_ACTION });
+ next.resetHistory();
+
+ const action = ac.AlsoToOneContent({ type: "FOO" }, 123);
+ dispatch(action);
+ assert.calledWith(next, action);
+ });
+ it("should not let startup actions go through for the preloaded about:home document", () => {
+ globals.set("__FROM_STARTUP_CACHE__", true);
+ const next = sinon.spy();
+ const dispatch = rehydrationMiddleware(store)(next);
+ const action = ac.BroadcastToContent(
+ { type: "FOO", meta: { isStartup: true } },
+ 123
+ );
+ dispatch(action);
+ assert.notCalled(next);
+ });
+ });
+ describe("queueEarlyMessageMiddleware", () => {
+ it("should allow all local actions to go through", () => {
+ const action = { type: "FOO" };
+ const next = sinon.spy();
+
+ queueEarlyMessageMiddleware(store)(next)(action);
+
+ assert.calledWith(next, action);
+ });
+ it("should allow action to main that does not belong to EARLY_QUEUED_ACTIONS to go through", () => {
+ const action = ac.AlsoToMain({ type: "FOO" });
+ const next = sinon.spy();
+
+ queueEarlyMessageMiddleware(store)(next)(action);
+
+ assert.calledWith(next, action);
+ });
+ it(`should line up EARLY_QUEUED_ACTIONS only let them go through after it receives the action from main`, () => {
+ EARLY_QUEUED_ACTIONS.forEach(actionType => {
+ const testStore = initStore({ number: addNumberReducer });
+ const next = sinon.spy();
+ const dispatch = queueEarlyMessageMiddleware(testStore)(next);
+ const action = ac.AlsoToMain({ type: actionType });
+ const fromMainAction = ac.AlsoToOneContent({ type: "FOO" }, 123);
+
+ // Early actions should be added to the queue
+ dispatch(action);
+ dispatch(action);
+
+ assert.notCalled(next);
+ assert.equal(testStore.getState.earlyActionQueue.length, 2);
+ next.resetHistory();
+
+ // Receiving action from main would empty the queue
+ dispatch(fromMainAction);
+
+ assert.calledThrice(next);
+ assert.equal(next.firstCall.args[0], fromMainAction);
+ assert.equal(next.secondCall.args[0], action);
+ assert.equal(next.thirdCall.args[0], action);
+ assert.equal(testStore.getState.earlyActionQueue.length, 0);
+ next.resetHistory();
+
+ // New action should go through immediately
+ dispatch(action);
+ assert.calledOnce(next);
+ assert.calledWith(next, action);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/lib/perf-service.test.js b/browser/components/newtab/test/unit/content-src/lib/perf-service.test.js
new file mode 100644
index 0000000000..9cabfb5029
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/lib/perf-service.test.js
@@ -0,0 +1,89 @@
+/* globals assert, beforeEach, describe, it */
+import { _PerfService } from "content-src/lib/perf-service";
+import { FakePerformance } from "test/unit/utils.js";
+
+let perfService;
+
+describe("_PerfService", () => {
+ let sandbox;
+ let fakePerfObj;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ fakePerfObj = new FakePerformance();
+ perfService = new _PerfService({ performanceObj: fakePerfObj });
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ describe("#absNow", () => {
+ it("should return a number > the time origin", () => {
+ const absNow = perfService.absNow();
+
+ assert.isAbove(absNow, perfService.timeOrigin);
+ });
+ });
+ describe("#getEntriesByName", () => {
+ it("should call getEntriesByName on the appropriate Window.performance", () => {
+ sandbox.spy(fakePerfObj, "getEntriesByName");
+
+ perfService.getEntriesByName("monkey", "mark");
+
+ assert.calledOnce(fakePerfObj.getEntriesByName);
+ assert.calledWithExactly(fakePerfObj.getEntriesByName, "monkey", "mark");
+ });
+
+ it("should return entries with the given name", () => {
+ sandbox.spy(fakePerfObj, "getEntriesByName");
+ perfService.mark("monkey");
+ perfService.mark("dog");
+
+ let marks = perfService.getEntriesByName("monkey", "mark");
+
+ assert.isArray(marks);
+ assert.lengthOf(marks, 1);
+ assert.propertyVal(marks[0], "name", "monkey");
+ });
+ });
+
+ describe("#getMostRecentAbsMarkStartByName", () => {
+ it("should throw an error if there is no mark with the given name", () => {
+ function bogusGet() {
+ perfService.getMostRecentAbsMarkStartByName("rheeeet");
+ }
+
+ assert.throws(bogusGet, Error, /No marks with the name/);
+ });
+
+ it("should return the Number from the most recent mark with the given name + the time origin", () => {
+ perfService.mark("dog");
+ perfService.mark("dog");
+
+ let absMarkStart = perfService.getMostRecentAbsMarkStartByName("dog");
+
+ // 2 because we want the result of the 2nd call to mark, and an instance
+ // of FakePerformance just returns the number of time mark has been
+ // called.
+ assert.equal(absMarkStart - perfService.timeOrigin, 2);
+ });
+ });
+
+ describe("#mark", () => {
+ it("should call the wrapped version of mark", () => {
+ sandbox.spy(fakePerfObj, "mark");
+
+ perfService.mark("monkey");
+
+ assert.calledOnce(fakePerfObj.mark);
+ assert.calledWithExactly(fakePerfObj.mark, "monkey");
+ });
+ });
+
+ describe("#timeOrigin", () => {
+ it("should get the origin of the wrapped performance object", () => {
+ assert.equal(perfService.timeOrigin, fakePerfObj.timeOrigin);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js b/browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js
new file mode 100644
index 0000000000..ef7e7cf5f6
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js
@@ -0,0 +1,147 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { ScreenshotUtils } from "content-src/lib/screenshot-utils";
+
+const DEFAULT_BLOB_URL = "blob://test";
+
+describe("ScreenshotUtils", () => {
+ let globals;
+ let url;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ url = {
+ createObjectURL: globals.sandbox.stub().returns(DEFAULT_BLOB_URL),
+ revokeObjectURL: globals.sandbox.spy(),
+ };
+ globals.set("URL", url);
+ });
+ afterEach(() => globals.restore());
+ describe("#createLocalImageObject", () => {
+ it("should return null if no remoteImage is supplied", () => {
+ let localImageObject = ScreenshotUtils.createLocalImageObject(null);
+
+ assert.notCalled(url.createObjectURL);
+ assert.equal(localImageObject, null);
+ });
+ it("should create a local image object with the correct properties if remoteImage is a blob", () => {
+ let localImageObject = ScreenshotUtils.createLocalImageObject({
+ path: "/path1",
+ data: new Blob([0]),
+ });
+
+ assert.calledOnce(url.createObjectURL);
+ assert.deepEqual(localImageObject, {
+ path: "/path1",
+ url: DEFAULT_BLOB_URL,
+ });
+ });
+ it("should create a local image object with the correct properties if remoteImage is a normal image", () => {
+ const imageUrl = "https://test-url";
+ let localImageObject = ScreenshotUtils.createLocalImageObject(imageUrl);
+
+ assert.notCalled(url.createObjectURL);
+ assert.deepEqual(localImageObject, { url: imageUrl });
+ });
+ });
+ describe("#maybeRevokeBlobObjectURL", () => {
+ // Note that we should also ensure that all the tests for #isBlob are green.
+ it("should call revokeObjectURL if image is a blob", () => {
+ ScreenshotUtils.maybeRevokeBlobObjectURL({
+ path: "/path1",
+ url: "blob://test",
+ });
+
+ assert.calledOnce(url.revokeObjectURL);
+ });
+ it("should not call revokeObjectURL if image is not a blob", () => {
+ ScreenshotUtils.maybeRevokeBlobObjectURL({ url: "https://test-url" });
+
+ assert.notCalled(url.revokeObjectURL);
+ });
+ });
+ describe("#isRemoteImageLocal", () => {
+ it("should return true if both propsImage and stateImage are not present", () => {
+ assert.isTrue(ScreenshotUtils.isRemoteImageLocal(null, null));
+ });
+ it("should return false if propsImage is present and stateImage is not present", () => {
+ assert.isFalse(ScreenshotUtils.isRemoteImageLocal(null, {}));
+ });
+ it("should return false if propsImage is not present and stateImage is present", () => {
+ assert.isFalse(ScreenshotUtils.isRemoteImageLocal({}, null));
+ });
+ it("should return true if both propsImage and stateImage are equal blobs", () => {
+ const blobPath = "/test-blob-path/test.png";
+ assert.isTrue(
+ ScreenshotUtils.isRemoteImageLocal(
+ { path: blobPath, url: "blob://test" }, // state
+ { path: blobPath, data: new Blob([0]) } // props
+ )
+ );
+ });
+ it("should return false if both propsImage and stateImage are different blobs", () => {
+ assert.isFalse(
+ ScreenshotUtils.isRemoteImageLocal(
+ { path: "/path1", url: "blob://test" }, // state
+ { path: "/path2", data: new Blob([0]) } // props
+ )
+ );
+ });
+ it("should return true if both propsImage and stateImage are equal normal images", () => {
+ assert.isTrue(
+ ScreenshotUtils.isRemoteImageLocal(
+ { url: "test url" }, // state
+ "test url" // props
+ )
+ );
+ });
+ it("should return false if both propsImage and stateImage are different normal images", () => {
+ assert.isFalse(
+ ScreenshotUtils.isRemoteImageLocal(
+ { url: "test url 1" }, // state
+ "test url 2" // props
+ )
+ );
+ });
+ it("should return false if both propsImage and stateImage are different type of images", () => {
+ assert.isFalse(
+ ScreenshotUtils.isRemoteImageLocal(
+ { path: "/path1", url: "blob://test" }, // state
+ "test url 2" // props
+ )
+ );
+ assert.isFalse(
+ ScreenshotUtils.isRemoteImageLocal(
+ { url: "https://test-url" }, // state
+ { path: "/path1", data: new Blob([0]) } // props
+ )
+ );
+ });
+ });
+ describe("#isBlob", () => {
+ let state = {
+ blobImage: { path: "/test", url: "blob://test" },
+ normalImage: { url: "https://test-url" },
+ };
+ let props = {
+ blobImage: { path: "/test", data: new Blob([0]) },
+ normalImage: "https://test-url",
+ };
+ it("should return false if image is null", () => {
+ assert.isFalse(ScreenshotUtils.isBlob(true, null));
+ assert.isFalse(ScreenshotUtils.isBlob(false, null));
+ });
+ it("should return true if image is a blob and type matches", () => {
+ assert.isTrue(ScreenshotUtils.isBlob(true, state.blobImage));
+ assert.isTrue(ScreenshotUtils.isBlob(false, props.blobImage));
+ });
+ it("should return false if image is not a blob and type matches", () => {
+ assert.isFalse(ScreenshotUtils.isBlob(true, state.normalImage));
+ assert.isFalse(ScreenshotUtils.isBlob(false, props.normalImage));
+ });
+ it("should return false if type does not match", () => {
+ assert.isFalse(ScreenshotUtils.isBlob(false, state.blobImage));
+ assert.isFalse(ScreenshotUtils.isBlob(false, state.normalImage));
+ assert.isFalse(ScreenshotUtils.isBlob(true, props.blobImage));
+ assert.isFalse(ScreenshotUtils.isBlob(true, props.normalImage));
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js b/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js
new file mode 100644
index 0000000000..233f31b6ca
--- /dev/null
+++ b/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js
@@ -0,0 +1,576 @@
+import { combineReducers, createStore } from "redux";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import { reducers } from "common/Reducers.sys.mjs";
+import { selectLayoutRender } from "content-src/lib/selectLayoutRender";
+const FAKE_LAYOUT = [
+ {
+ width: 3,
+ components: [
+ { type: "foo", feed: { url: "foo.com" }, properties: { items: 2 } },
+ ],
+ },
+];
+const FAKE_FEEDS = {
+ "foo.com": { data: { recommendations: [{ id: "foo" }, { id: "bar" }] } },
+};
+
+describe("selectLayoutRender", () => {
+ let store;
+ let globals;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ store = createStore(combineReducers(reducers));
+ });
+
+ afterEach(() => {
+ globals.restore();
+ });
+
+ it("should return an empty array given initial state", () => {
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ prefs: {},
+ rollCache: [],
+ });
+ assert.deepEqual(layoutRender, []);
+ });
+
+ it("should add .data property from feeds to each compontent in .layout", () => {
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: FAKE_LAYOUT },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: FAKE_FEEDS["foo.com"], url: "foo.com" },
+ });
+ store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.lengthOf(layoutRender, 1);
+ assert.propertyVal(layoutRender[0], "width", 3);
+ assert.deepEqual(layoutRender[0].components[0], {
+ type: "foo",
+ feed: { url: "foo.com" },
+ properties: { items: 2 },
+ data: {
+ recommendations: [
+ { id: "foo", pos: 0 },
+ { id: "bar", pos: 1 },
+ ],
+ },
+ });
+ });
+
+ it("should return layout with placeholder data if feed doesn't have data", () => {
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: FAKE_LAYOUT },
+ });
+ store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.lengthOf(layoutRender, 1);
+ assert.propertyVal(layoutRender[0], "width", 3);
+ assert.deepEqual(layoutRender[0].components[0].data.recommendations, [
+ { placeholder: true },
+ { placeholder: true },
+ ]);
+ });
+
+ it("should return layout with empty spocs data if feed isn't defined but spocs is", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [{ type: "foo", spocs: { positions: [{ index: 2 }] } }],
+ },
+ ];
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.lengthOf(layoutRender, 1);
+ assert.propertyVal(layoutRender[0], "width", 3);
+ assert.deepEqual(layoutRender[0].components[0].data.spocs, []);
+ });
+
+ it("should return layout with spocs data if feed isn't defined but spocs is", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [{ type: "foo", spocs: { positions: [{ index: 0 }] } }],
+ },
+ ];
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
+ data: {
+ lastUpdated: 0,
+ spocs: {
+ spocs: {
+ items: [{ id: 1 }, { id: 2 }, { id: 3 }],
+ },
+ },
+ },
+ });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.lengthOf(layoutRender, 1);
+ assert.propertyVal(layoutRender[0], "width", 3);
+ assert.deepEqual(layoutRender[0].components[0].data.spocs, [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ { id: 3, pos: 2 },
+ ]);
+ });
+
+ it("should return layout with no spocs data if feed and spocs are unavailable", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [{ type: "foo", spocs: { positions: [{ index: 0 }] } }],
+ },
+ ];
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
+ data: {
+ lastUpdated: 0,
+ spocs: {
+ spocs: {
+ items: [],
+ },
+ },
+ },
+ });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.lengthOf(layoutRender, 1);
+ assert.propertyVal(layoutRender[0], "width", 3);
+ assert.equal(layoutRender[0].components[0].data.spocs.length, 0);
+ });
+
+ it("should return feed data offset by layout set prop", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [
+ { type: "foo", properties: { offset: 1 }, feed: { url: "foo.com" } },
+ ],
+ },
+ ];
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: FAKE_FEEDS["foo.com"], url: "foo.com" },
+ });
+ store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.deepEqual(layoutRender[0].components[0].data, {
+ recommendations: [{ id: "bar" }],
+ });
+ });
+
+ it("should return spoc result when there are more positions than spocs", () => {
+ const fakeSpocConfig = {
+ positions: [{ index: 0 }, { index: 1 }, { index: 2 }],
+ };
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [
+ { type: "foo", feed: { url: "foo.com" }, spocs: fakeSpocConfig },
+ ],
+ },
+ ];
+ const fakeSpocsData = {
+ lastUpdated: 0,
+ spocs: { spocs: { items: ["fooSpoc", "barSpoc"] } },
+ };
+
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: FAKE_FEEDS["foo.com"], url: "foo.com" },
+ });
+ store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
+ data: fakeSpocsData,
+ });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.lengthOf(layoutRender, 1);
+ assert.deepEqual(
+ layoutRender[0].components[0].data.recommendations[0],
+ "fooSpoc"
+ );
+ assert.deepEqual(
+ layoutRender[0].components[0].data.recommendations[1],
+ "barSpoc"
+ );
+ assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], {
+ id: "foo",
+ });
+ assert.deepEqual(layoutRender[0].components[0].data.recommendations[3], {
+ id: "bar",
+ });
+ });
+
+ it("should return a layout with feeds of items length with positions", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [
+ { type: "foo", properties: { items: 3 }, feed: { url: "foo.com" } },
+ ],
+ },
+ ];
+ const fakeRecommendations = [
+ { name: "item1" },
+ { name: "item2" },
+ { name: "item3" },
+ { name: "item4" },
+ ];
+ const fakeFeeds = {
+ "foo.com": { data: { recommendations: fakeRecommendations } },
+ };
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: fakeFeeds["foo.com"], url: "foo.com" },
+ });
+ store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ const { recommendations } = layoutRender[0].components[0].data;
+ assert.equal(recommendations.length, 4);
+ assert.equal(recommendations[0].pos, 0);
+ assert.equal(recommendations[1].pos, 1);
+ assert.equal(recommendations[2].pos, 2);
+ assert.equal(recommendations[3].pos, undefined);
+ });
+ it("should stop rendering feeds if we hit one that's not ready", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [
+ { type: "foo1" },
+ { type: "foo2", properties: { items: 3 }, feed: { url: "foo2.com" } },
+ { type: "foo3", properties: { items: 3 }, feed: { url: "foo3.com" } },
+ { type: "foo4", properties: { items: 3 }, feed: { url: "foo4.com" } },
+ { type: "foo5" },
+ ],
+ },
+ ];
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { recommendations: [] } }, url: "foo2.com" },
+ });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.equal(layoutRender[0].components[0].type, "foo1");
+ assert.equal(layoutRender[0].components[1].type, "foo2");
+ assert.isTrue(
+ layoutRender[0].components[2].data.recommendations[0].placeholder
+ );
+ assert.lengthOf(layoutRender[0].components, 3);
+ assert.isUndefined(layoutRender[0].components[3]);
+ });
+ it("should render everything if everything is ready", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [
+ { type: "foo1" },
+ { type: "foo2", properties: { items: 3 }, feed: { url: "foo2.com" } },
+ { type: "foo3", properties: { items: 3 }, feed: { url: "foo3.com" } },
+ { type: "foo4", properties: { items: 3 }, feed: { url: "foo4.com" } },
+ { type: "foo5" },
+ ],
+ },
+ ];
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { recommendations: [] } }, url: "foo2.com" },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { recommendations: [] } }, url: "foo3.com" },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { recommendations: [] } }, url: "foo4.com" },
+ });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.equal(layoutRender[0].components[0].type, "foo1");
+ assert.equal(layoutRender[0].components[1].type, "foo2");
+ assert.equal(layoutRender[0].components[2].type, "foo3");
+ assert.equal(layoutRender[0].components[3].type, "foo4");
+ assert.equal(layoutRender[0].components[4].type, "foo5");
+ });
+ it("should stop rendering feeds if we hit a not ready spoc", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [
+ { type: "foo1" },
+ { type: "foo2", properties: { items: 3 }, feed: { url: "foo2.com" } },
+ {
+ type: "foo3",
+ properties: { items: 3 },
+ feed: { url: "foo3.com" },
+ spocs: { positions: [{ index: 0 }] },
+ },
+ { type: "foo4", properties: { items: 3 }, feed: { url: "foo4.com" } },
+ { type: "foo5" },
+ ],
+ },
+ ];
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { recommendations: [] } }, url: "foo2.com" },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { recommendations: [] } }, url: "foo3.com" },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { recommendations: [] } }, url: "foo4.com" },
+ });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.equal(layoutRender[0].components[0].type, "foo1");
+ assert.equal(layoutRender[0].components[1].type, "foo2");
+ assert.deepEqual(layoutRender[0].components[2].data.recommendations, [
+ { placeholder: true },
+ { placeholder: true },
+ { placeholder: true },
+ ]);
+ });
+ it("should not render a spoc if there are no available spocs", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [
+ { type: "foo1" },
+ { type: "foo2", properties: { items: 3 }, feed: { url: "foo2.com" } },
+ {
+ type: "foo3",
+ properties: { items: 3 },
+ feed: { url: "foo3.com" },
+ spocs: { positions: [{ index: 0 }] },
+ },
+ { type: "foo4", properties: { items: 3 }, feed: { url: "foo4.com" } },
+ { type: "foo5" },
+ ],
+ },
+ ];
+ const fakeSpocsData = { lastUpdated: 0, spocs: { spocs: [] } };
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { recommendations: [] } }, url: "foo2.com" },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: {
+ feed: { data: { recommendations: [{ name: "rec" }] } },
+ url: "foo3.com",
+ },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { recommendations: [] } }, url: "foo4.com" },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
+ data: fakeSpocsData,
+ });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.deepEqual(layoutRender[0].components[2].data.recommendations[0], {
+ name: "rec",
+ pos: 0,
+ });
+ });
+ it("should not render a row if no components exist after filter in that row", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [{ type: "TopSites" }],
+ },
+ {
+ width: 3,
+ components: [{ type: "Message" }],
+ },
+ ];
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ prefs: { "feeds.topsites": true },
+ });
+
+ assert.equal(layoutRender[0].components[0].type, "TopSites");
+ assert.equal(layoutRender[1], undefined);
+ });
+ it("should not render a component if filtered", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [{ type: "Message" }, { type: "TopSites" }],
+ },
+ ];
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+
+ const { layoutRender } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ prefs: { "feeds.topsites": true },
+ });
+
+ assert.equal(layoutRender[0].components[0].type, "TopSites");
+ assert.equal(layoutRender[0].components[1], undefined);
+ });
+ it("should skip rendering a spoc in position if that spoc is blocked for that session", () => {
+ const fakeLayout = [
+ {
+ width: 3,
+ components: [
+ {
+ type: "foo1",
+ properties: { items: 3 },
+ feed: { url: "foo1.com" },
+ spocs: { positions: [{ index: 0 }] },
+ },
+ ],
+ },
+ ];
+ const fakeSpocsData = {
+ lastUpdated: 0,
+ spocs: {
+ spocs: { items: [{ name: "spoc", url: "https://foo.com" }] },
+ },
+ };
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
+ data: { layout: fakeLayout },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: {
+ feed: { data: { recommendations: [{ name: "rec" }] } },
+ url: "foo1.com",
+ },
+ });
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
+ data: fakeSpocsData,
+ });
+
+ const { layoutRender: layout1 } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ store.dispatch({
+ type: at.DISCOVERY_STREAM_SPOC_BLOCKED,
+ data: { url: "https://foo.com" },
+ });
+
+ const { layoutRender: layout2 } = selectLayoutRender({
+ state: store.getState().DiscoveryStream,
+ });
+
+ assert.deepEqual(layout1[0].components[0].data.recommendations[0], {
+ name: "spoc",
+ url: "https://foo.com",
+ pos: 0,
+ });
+ assert.deepEqual(layout2[0].components[0].data.recommendations[0], {
+ name: "rec",
+ pos: 0,
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/AboutPreferences.test.js b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js
new file mode 100644
index 0000000000..f355c6f0ab
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js
@@ -0,0 +1,429 @@
+/* global Services */
+import {
+ AboutPreferences,
+ PREFERENCES_LOADED_EVENT,
+} from "lib/AboutPreferences.jsm";
+import {
+ actionTypes as at,
+ actionCreators as ac,
+} from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("AboutPreferences Feed", () => {
+ let globals;
+ let sandbox;
+ let Sections;
+ let DiscoveryStream;
+ let instance;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ Sections = [];
+ DiscoveryStream = { config: { enabled: false } };
+ instance = new AboutPreferences();
+ instance.store = {
+ dispatch: sandbox.stub(),
+ getState: () => ({ Sections, DiscoveryStream }),
+ };
+ globals.set("NimbusFeatures", {
+ newtab: { getAllVariables: sandbox.stub() },
+ });
+ });
+ afterEach(() => {
+ globals.restore();
+ });
+
+ describe("#onAction", () => {
+ it("should call .init() on an INIT action", () => {
+ const stub = sandbox.stub(instance, "init");
+
+ instance.onAction({ type: at.INIT });
+
+ assert.calledOnce(stub);
+ });
+ it("should call .uninit() on an UNINIT action", () => {
+ const stub = sandbox.stub(instance, "uninit");
+
+ instance.onAction({ type: at.UNINIT });
+
+ assert.calledOnce(stub);
+ });
+ it("should call .openPreferences on SETTINGS_OPEN", () => {
+ const action = {
+ type: at.SETTINGS_OPEN,
+ _target: { browser: { ownerGlobal: { openPreferences: sinon.spy() } } },
+ };
+ instance.onAction(action);
+ assert.calledOnce(action._target.browser.ownerGlobal.openPreferences);
+ });
+ it("should call .BrowserOpenAddonsMgr with the extension id on OPEN_WEBEXT_SETTINGS", () => {
+ const action = {
+ type: at.OPEN_WEBEXT_SETTINGS,
+ data: "foo",
+ _target: {
+ browser: { ownerGlobal: { BrowserOpenAddonsMgr: sinon.spy() } },
+ },
+ };
+ instance.onAction(action);
+ assert.calledWith(
+ action._target.browser.ownerGlobal.BrowserOpenAddonsMgr,
+ "addons://detail/foo"
+ );
+ });
+ });
+ describe("#observe", () => {
+ it("should watch for about:preferences loading", () => {
+ sandbox.stub(Services.obs, "addObserver");
+
+ instance.init();
+
+ assert.calledOnce(Services.obs.addObserver);
+ assert.calledWith(
+ Services.obs.addObserver,
+ instance,
+ PREFERENCES_LOADED_EVENT
+ );
+ });
+ it("should stop watching on uninit", () => {
+ sandbox.stub(Services.obs, "removeObserver");
+
+ instance.uninit();
+
+ assert.calledOnce(Services.obs.removeObserver);
+ assert.calledWith(
+ Services.obs.removeObserver,
+ instance,
+ PREFERENCES_LOADED_EVENT
+ );
+ });
+ it("should try to render on event", async () => {
+ const stub = sandbox.stub(instance, "renderPreferences");
+ Sections.push({});
+
+ await instance.observe(window, PREFERENCES_LOADED_EVENT);
+
+ assert.calledOnce(stub);
+ assert.equal(stub.firstCall.args[0], window);
+ assert.include(stub.firstCall.args[1], Sections[0]);
+ });
+ it("Hide topstories rows select in sections if discovery stream is enabled", async () => {
+ const stub = sandbox.stub(instance, "renderPreferences");
+
+ Sections.push({
+ rowsPref: "row_pref",
+ maxRows: 3,
+ pref: { descString: "foo" },
+ learnMore: { link: "https://foo.com" },
+ id: "topstories",
+ });
+ DiscoveryStream = { config: { enabled: true } };
+
+ await instance.observe(window, PREFERENCES_LOADED_EVENT);
+
+ assert.calledOnce(stub);
+ const [, structure] = stub.firstCall.args;
+ assert.equal(structure[0].id, "search");
+ assert.equal(structure[1].id, "topsites");
+ assert.equal(structure[2].id, "topstories");
+ assert.isEmpty(structure[2].rowsPref);
+ });
+ });
+ describe("#renderPreferences", () => {
+ let node;
+ let prefStructure;
+ let Preferences;
+ let gHomePane;
+ const testRender = () =>
+ instance.renderPreferences(
+ {
+ document: {
+ createXULElement: sandbox.stub().returns(node),
+ l10n: {
+ setAttributes(el, id, args) {
+ el.setAttribute("data-l10n-id", id);
+ el.setAttribute("data-l10n-args", JSON.stringify(args));
+ },
+ },
+ createProcessingInstruction: sandbox.stub(),
+ createElementNS: sandbox.stub().callsFake((NS, el) => node),
+ getElementById: sandbox.stub().returns(node),
+ insertBefore: sandbox.stub().returnsArg(0),
+ querySelector: sandbox
+ .stub()
+ .returns({ appendChild: sandbox.stub() }),
+ },
+ Preferences,
+ gHomePane,
+ },
+ prefStructure,
+ DiscoveryStream.config
+ );
+ beforeEach(() => {
+ node = {
+ appendChild: sandbox.stub().returnsArg(0),
+ addEventListener: sandbox.stub(),
+ classList: { add: sandbox.stub(), remove: sandbox.stub() },
+ cloneNode: sandbox.stub().returnsThis(),
+ insertAdjacentElement: sandbox.stub().returnsArg(1),
+ setAttribute: sandbox.stub(),
+ remove: sandbox.stub(),
+ style: {},
+ };
+ prefStructure = [];
+ Preferences = {
+ add: sandbox.stub(),
+ get: sandbox.stub().returns({
+ on: sandbox.stub(),
+ }),
+ };
+ gHomePane = { toggleRestoreDefaultsBtn: sandbox.stub() };
+ });
+ describe("#getString", () => {
+ it("should not fail if titleString is not provided", () => {
+ prefStructure = [{ pref: {} }];
+
+ testRender();
+ assert.calledWith(
+ node.setAttribute,
+ "data-l10n-id",
+ sinon.match.typeOf("undefined")
+ );
+ });
+ it("should return the string id if titleString is just a string", () => {
+ const titleString = "foo";
+ prefStructure = [{ pref: { titleString } }];
+
+ testRender();
+ assert.calledWith(node.setAttribute, "data-l10n-id", titleString);
+ });
+ it("should set id and args if titleString is an object with id and values", () => {
+ const titleString = { id: "foo", values: { provider: "bar" } };
+ prefStructure = [{ pref: { titleString } }];
+
+ testRender();
+ assert.calledWith(node.setAttribute, "data-l10n-id", titleString.id);
+ assert.calledWith(
+ node.setAttribute,
+ "data-l10n-args",
+ JSON.stringify(titleString.values)
+ );
+ });
+ });
+ describe("#linkPref", () => {
+ it("should add a pref to the global", () => {
+ prefStructure = [{ pref: { feed: "feed" } }];
+
+ testRender();
+
+ assert.calledOnce(Preferences.add);
+ });
+ it("should skip adding if not shown", () => {
+ prefStructure = [{ shouldHidePref: true }];
+
+ testRender();
+
+ assert.notCalled(Preferences.add);
+ });
+ });
+ describe("pref icon", () => {
+ it("should default to webextension icon", () => {
+ prefStructure = [{ pref: { feed: "feed" } }];
+
+ testRender();
+
+ assert.calledWith(
+ node.setAttribute,
+ "src",
+ "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg"
+ );
+ });
+ it("should use desired glyph icon", () => {
+ prefStructure = [{ icon: "mail", pref: { feed: "feed" } }];
+
+ testRender();
+
+ assert.calledWith(
+ node.setAttribute,
+ "src",
+ "chrome://activity-stream/content/data/content/assets/glyph-mail-16.svg"
+ );
+ });
+ it("should use specified chrome icon", () => {
+ const icon = "chrome://the/icon.svg";
+ prefStructure = [{ icon, pref: { feed: "feed" } }];
+
+ testRender();
+
+ assert.calledWith(node.setAttribute, "src", icon);
+ });
+ });
+ describe("title line", () => {
+ it("should render a title", () => {
+ const titleString = "the_title";
+ prefStructure = [{ pref: { titleString } }];
+
+ testRender();
+
+ assert.calledWith(node.setAttribute, "data-l10n-id", titleString);
+ });
+ });
+ describe("top stories", () => {
+ const href = "https://disclaimer/";
+ const eventSource = "https://disclaimer/";
+ beforeEach(() => {
+ prefStructure = [
+ {
+ id: "topstories",
+ pref: { feed: "feed", learnMore: { link: { href } } },
+ eventSource,
+ },
+ ];
+ });
+ it("should add a link for top stories", () => {
+ testRender();
+ assert.calledWith(node.setAttribute, "href", href);
+ });
+ it("should setup a user event for top stories eventSource", () => {
+ sinon.spy(instance, "setupUserEvent");
+ testRender();
+ assert.calledWith(node.addEventListener, "command");
+ assert.calledWith(instance.setupUserEvent, node, eventSource);
+ });
+ it("should setup a user event for top stories nested pref eventSource", () => {
+ sinon.spy(instance, "setupUserEvent");
+ prefStructure = [
+ {
+ id: "topstories",
+ pref: {
+ feed: "feed",
+ learnMore: { link: { href } },
+ nestedPrefs: [
+ {
+ name: "showSponsored",
+ titleString:
+ "home-prefs-recommended-by-option-sponsored-stories",
+ icon: "icon-info",
+ eventSource: "POCKET_SPOCS",
+ },
+ ],
+ },
+ },
+ ];
+ testRender();
+ assert.calledWith(node.addEventListener, "command");
+ assert.calledWith(instance.setupUserEvent, node, "POCKET_SPOCS");
+ });
+ it("should fire store dispatch with onCommand", () => {
+ const element = {
+ addEventListener: (command, action) => {
+ // Trigger the action right away because we only care about testing the action here.
+ action({ target: { checked: true } });
+ },
+ };
+ instance.setupUserEvent(element, eventSource);
+ assert.calledWith(
+ instance.store.dispatch,
+ ac.UserEvent({
+ event: "PREF_CHANGED",
+ source: eventSource,
+ value: { menu_source: "ABOUT_PREFERENCES", status: true },
+ })
+ );
+ });
+ });
+ describe("description line", () => {
+ it("should render a description", () => {
+ const descString = "the_desc";
+ prefStructure = [{ pref: { descString } }];
+
+ testRender();
+
+ assert.calledWith(node.setAttribute, "data-l10n-id", descString);
+ });
+ it("should render rows dropdown with appropriate number", () => {
+ prefStructure = [
+ { rowsPref: "row_pref", maxRows: 3, pref: { descString: "foo" } },
+ ];
+
+ testRender();
+
+ assert.calledWith(node.setAttribute, "value", 1);
+ assert.calledWith(node.setAttribute, "value", 2);
+ assert.calledWith(node.setAttribute, "value", 3);
+ });
+ });
+ describe("nested prefs", () => {
+ const titleString = "im_nested";
+ beforeEach(() => {
+ prefStructure = [{ pref: { nestedPrefs: [{ titleString }] } }];
+ });
+ it("should render a nested pref", () => {
+ testRender();
+
+ assert.calledWith(node.setAttribute, "data-l10n-id", titleString);
+ });
+ it("should set node hidden to true", () => {
+ prefStructure[0].pref.nestedPrefs[0].hidden = true;
+
+ testRender();
+
+ assert.isTrue(node.hidden);
+ });
+ it("should add a change event", () => {
+ testRender();
+
+ assert.calledOnce(Preferences.get().on);
+ assert.calledWith(Preferences.get().on, "change");
+ });
+ it("should default node disabled to false", async () => {
+ Preferences.get = sandbox.stub().returns({
+ on: sandbox.stub(),
+ _value: true,
+ });
+
+ testRender();
+
+ assert.isFalse(node.disabled);
+ });
+ it("should default node disabled to true", async () => {
+ testRender();
+
+ assert.isTrue(node.disabled);
+ });
+ it("should set node disabled to true", async () => {
+ const pref = {
+ on: sandbox.stub(),
+ _value: true,
+ };
+ Preferences.get = sandbox.stub().returns(pref);
+
+ testRender();
+ pref._value = !pref._value;
+ await Preferences.get().on.firstCall.args[1]();
+
+ assert.isTrue(node.disabled);
+ });
+ it("should set node disabled to false", async () => {
+ const pref = {
+ on: sandbox.stub(),
+ _value: false,
+ };
+ Preferences.get = sandbox.stub().returns(pref);
+
+ testRender();
+ pref._value = !pref._value;
+ await Preferences.get().on.firstCall.args[1]();
+
+ assert.isFalse(node.disabled);
+ });
+ });
+ describe("restore defaults btn", () => {
+ it("should call toggleRestoreDefaultsBtn", () => {
+ testRender();
+
+ assert.calledOnce(gHomePane.toggleRestoreDefaultsBtn);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ActivityStream.test.js b/browser/components/newtab/test/unit/lib/ActivityStream.test.js
new file mode 100644
index 0000000000..47880d00bc
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ActivityStream.test.js
@@ -0,0 +1,576 @@
+import { CONTENT_MESSAGE_TYPE } from "common/Actions.sys.mjs";
+import { ActivityStream, PREFS_CONFIG } from "lib/ActivityStream.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+import { DEFAULT_SITES } from "lib/DefaultSites.sys.mjs";
+import { AboutPreferences } from "lib/AboutPreferences.jsm";
+import { DefaultPrefs } from "lib/ActivityStreamPrefs.jsm";
+import { NewTabInit } from "lib/NewTabInit.jsm";
+import { SectionsFeed } from "lib/SectionsManager.jsm";
+import { RecommendationProvider } from "lib/RecommendationProvider.jsm";
+import { PlacesFeed } from "lib/PlacesFeed.jsm";
+import { PrefsFeed } from "lib/PrefsFeed.jsm";
+import { SystemTickFeed } from "lib/SystemTickFeed.jsm";
+import { TelemetryFeed } from "lib/TelemetryFeed.jsm";
+import { FaviconFeed } from "lib/FaviconFeed.jsm";
+import { TopSitesFeed } from "lib/TopSitesFeed.jsm";
+import { TopStoriesFeed } from "lib/TopStoriesFeed.jsm";
+import { HighlightsFeed } from "lib/HighlightsFeed.jsm";
+import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.jsm";
+
+import { LinksCache } from "lib/LinksCache.sys.mjs";
+import { PersistentCache } from "lib/PersistentCache.sys.mjs";
+import { DownloadsManager } from "lib/DownloadsManager.jsm";
+
+describe("ActivityStream", () => {
+ let sandbox;
+ let as;
+ function FakeStore() {
+ return { init: () => {}, uninit: () => {}, feeds: { get: () => {} } };
+ }
+
+ let globals;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ globals.set({
+ Store: FakeStore,
+
+ DEFAULT_SITES,
+ AboutPreferences,
+ DefaultPrefs,
+ NewTabInit,
+ SectionsFeed,
+ RecommendationProvider,
+ PlacesFeed,
+ PrefsFeed,
+ SystemTickFeed,
+ TelemetryFeed,
+ FaviconFeed,
+ TopSitesFeed,
+ TopStoriesFeed,
+ HighlightsFeed,
+ DiscoveryStreamFeed,
+
+ LinksCache,
+ PersistentCache,
+ DownloadsManager,
+ });
+
+ as = new ActivityStream();
+ sandbox = sinon.createSandbox();
+ sandbox.stub(as.store, "init");
+ sandbox.stub(as.store, "uninit");
+ sandbox.stub(as._defaultPrefs, "init");
+ PREFS_CONFIG.get("feeds.system.topstories").value = undefined;
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ it("should exist", () => {
+ assert.ok(ActivityStream);
+ });
+ it("should initialize with .initialized=false", () => {
+ assert.isFalse(as.initialized, ".initialized");
+ });
+ describe("#init", () => {
+ beforeEach(() => {
+ as.init();
+ });
+ it("should initialize default prefs", () => {
+ assert.calledOnce(as._defaultPrefs.init);
+ });
+ it("should set .initialized to true", () => {
+ assert.isTrue(as.initialized, ".initialized");
+ });
+ it("should call .store.init", () => {
+ assert.calledOnce(as.store.init);
+ });
+ it("should pass to Store an INIT event for content", () => {
+ as.init();
+
+ const [, action] = as.store.init.firstCall.args;
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ it("should pass to Store an UNINIT event", () => {
+ as.init();
+
+ const [, , action] = as.store.init.firstCall.args;
+ assert.equal(action.type, "UNINIT");
+ });
+ it("should clear old default discoverystream config pref", () => {
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .returns(
+ `{"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"}`
+ );
+ sandbox.stub(global.Services.prefs, "clearUserPref");
+
+ as.init();
+
+ assert.calledWith(
+ global.Services.prefs.clearUserPref,
+ "browser.newtabpage.activity-stream.discoverystream.config"
+ );
+ });
+ it("should call addObserver for the app locales", () => {
+ sandbox.stub(global.Services.obs, "addObserver");
+ as.init();
+ assert.calledWith(
+ global.Services.obs.addObserver,
+ as,
+ "intl:app-locales-changed"
+ );
+ });
+ });
+ describe("#uninit", () => {
+ beforeEach(() => {
+ as.init();
+ as.uninit();
+ });
+ it("should set .initialized to false", () => {
+ assert.isFalse(as.initialized, ".initialized");
+ });
+ it("should call .store.uninit", () => {
+ assert.calledOnce(as.store.uninit);
+ });
+ it("should call removeObserver for the region", () => {
+ sandbox.stub(global.Services.obs, "removeObserver");
+ as.geo = "";
+ as.uninit();
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ as,
+ global.Region.REGION_TOPIC
+ );
+ });
+ it("should call removeObserver for the app locales", () => {
+ sandbox.stub(global.Services.obs, "removeObserver");
+ as.uninit();
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ as,
+ "intl:app-locales-changed"
+ );
+ });
+ });
+ describe("#observe", () => {
+ it("should call _updateDynamicPrefs from observe", () => {
+ sandbox.stub(as, "_updateDynamicPrefs");
+ as.observe(undefined, global.Region.REGION_TOPIC);
+ assert.calledOnce(as._updateDynamicPrefs);
+ });
+ });
+ describe("feeds", () => {
+ it("should create a NewTabInit feed", () => {
+ const feed = as.feeds.get("feeds.newtabinit")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a Places feed", () => {
+ const feed = as.feeds.get("feeds.places")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a TopSites feed", () => {
+ const feed = as.feeds.get("feeds.system.topsites")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a Telemetry feed", () => {
+ const feed = as.feeds.get("feeds.telemetry")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a Prefs feed", () => {
+ const feed = as.feeds.get("feeds.prefs")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a HighlightsFeed feed", () => {
+ const feed = as.feeds.get("feeds.section.highlights")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a TopStoriesFeed feed", () => {
+ const feed = as.feeds.get("feeds.system.topstories")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a AboutPreferences feed", () => {
+ const feed = as.feeds.get("feeds.aboutpreferences")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a SectionsFeed", () => {
+ const feed = as.feeds.get("feeds.sections")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a SystemTick feed", () => {
+ const feed = as.feeds.get("feeds.systemtick")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a Favicon feed", () => {
+ const feed = as.feeds.get("feeds.favicon")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a RecommendationProvider feed", () => {
+ const feed = as.feeds.get("feeds.recommendationprovider")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a DiscoveryStreamFeed feed", () => {
+ const feed = as.feeds.get("feeds.discoverystreamfeed")();
+ assert.ok(feed, "feed should exist");
+ });
+ });
+ describe("_migratePref", () => {
+ it("should migrate a pref if the user has set a custom value", () => {
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
+ sandbox.stub(global.Services.prefs, "getPrefType").returns("integer");
+ sandbox.stub(global.Services.prefs, "getIntPref").returns(10);
+ as._migratePref("oldPrefName", result => assert.equal(10, result));
+ });
+ it("should not migrate a pref if the user has not set a custom value", () => {
+ // we bailed out early so we don't check the pref type later
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(false);
+ sandbox.stub(global.Services.prefs, "getPrefType");
+ as._migratePref("oldPrefName");
+ assert.notCalled(global.Services.prefs.getPrefType);
+ });
+ it("should use the proper pref getter for each type", () => {
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
+
+ // Integer
+ sandbox.stub(global.Services.prefs, "getIntPref");
+ sandbox.stub(global.Services.prefs, "getPrefType").returns("integer");
+ as._migratePref("oldPrefName", () => {});
+ assert.calledWith(global.Services.prefs.getIntPref, "oldPrefName");
+
+ // Boolean
+ sandbox.stub(global.Services.prefs, "getBoolPref");
+ global.Services.prefs.getPrefType.returns("boolean");
+ as._migratePref("oldPrefName", () => {});
+ assert.calledWith(global.Services.prefs.getBoolPref, "oldPrefName");
+
+ // String
+ sandbox.stub(global.Services.prefs, "getStringPref");
+ global.Services.prefs.getPrefType.returns("string");
+ as._migratePref("oldPrefName", () => {});
+ assert.calledWith(global.Services.prefs.getStringPref, "oldPrefName");
+ });
+ it("should clear the old pref after setting the new one", () => {
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
+ sandbox.stub(global.Services.prefs, "clearUserPref");
+ sandbox.stub(global.Services.prefs, "getPrefType").returns("integer");
+ as._migratePref("oldPrefName", () => {});
+ assert.calledWith(global.Services.prefs.clearUserPref, "oldPrefName");
+ });
+ });
+ describe("discoverystream.region-basic-layout config", () => {
+ let getStringPrefStub;
+ beforeEach(() => {
+ getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
+ sandbox.stub(global.Region, "home").get(() => "CA");
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-CA");
+ });
+ it("should enable 7 row layout pref if no basic config is set and no geo is set", () => {
+ getStringPrefStub
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-basic-config"
+ )
+ .returns("");
+ sandbox.stub(global.Region, "home").get(() => "");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(
+ PREFS_CONFIG.get("discoverystream.region-basic-layout").value
+ );
+ });
+ it("should enable 1 row layout pref based on region layout pref", () => {
+ getStringPrefStub
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-basic-config"
+ )
+ .returns("CA");
+
+ as._updateDynamicPrefs();
+
+ assert.isTrue(
+ PREFS_CONFIG.get("discoverystream.region-basic-layout").value
+ );
+ });
+ it("should enable 7 row layout pref based on region layout pref", () => {
+ getStringPrefStub
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-basic-config"
+ )
+ .returns("");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(
+ PREFS_CONFIG.get("discoverystream.region-basic-layout").value
+ );
+ });
+ });
+ describe("_updateDynamicPrefs topstories default value", () => {
+ let getVariableStub;
+ let getBoolPrefStub;
+ let appLocaleAsBCP47Stub;
+ beforeEach(() => {
+ getVariableStub = sandbox.stub(
+ global.NimbusFeatures.pocketNewtab,
+ "getVariable"
+ );
+ appLocaleAsBCP47Stub = sandbox.stub(
+ global.Services.locale,
+ "appLocaleAsBCP47"
+ );
+
+ getBoolPrefStub = sandbox.stub(global.Services.prefs, "getBoolPref");
+ getBoolPrefStub
+ .withArgs("browser.newtabpage.activity-stream.feeds.section.topstories")
+ .returns(true);
+
+ appLocaleAsBCP47Stub.get(() => "en-US");
+
+ sandbox.stub(global.Region, "home").get(() => "US");
+
+ getVariableStub.withArgs("regionStoriesConfig").returns("US,CA");
+ });
+ it("should be false with no geo/locale", () => {
+ appLocaleAsBCP47Stub.get(() => "");
+ sandbox.stub(global.Region, "home").get(() => "");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be false with no geo but an allowed locale", () => {
+ appLocaleAsBCP47Stub.get(() => "");
+ sandbox.stub(global.Region, "home").get(() => "");
+ appLocaleAsBCP47Stub.get(() => "en-US");
+ getVariableStub
+ .withArgs("localeListConfig")
+ .returns("en-US,en-CA,en-GB")
+ // We only have this pref set to trigger a close to real situation.
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-stories-block"
+ )
+ .returns("FR");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be false with unexpected geo", () => {
+ sandbox.stub(global.Region, "home").get(() => "NOGEO");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be false with expected geo and unexpected locale", () => {
+ appLocaleAsBCP47Stub.get(() => "no-LOCALE");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be true with expected geo and locale", () => {
+ as._updateDynamicPrefs();
+ assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be false after expected geo and locale then unexpected", () => {
+ sandbox
+ .stub(global.Region, "home")
+ .onFirstCall()
+ .get(() => "US")
+ .onSecondCall()
+ .get(() => "NOGEO");
+
+ as._updateDynamicPrefs();
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be true with updated pref change", () => {
+ appLocaleAsBCP47Stub.get(() => "en-GB");
+ sandbox.stub(global.Region, "home").get(() => "GB");
+ getVariableStub.withArgs("regionStoriesConfig").returns("GB");
+
+ as._updateDynamicPrefs();
+
+ assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be true with allowed locale in non US region", () => {
+ appLocaleAsBCP47Stub.get(() => "en-CA");
+ sandbox.stub(global.Region, "home").get(() => "DE");
+ getVariableStub.withArgs("localeListConfig").returns("en-US,en-CA,en-GB");
+
+ as._updateDynamicPrefs();
+
+ assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ });
+ describe("_updateDynamicPrefs topstories delayed default value", () => {
+ let clock;
+ beforeEach(() => {
+ clock = sinon.useFakeTimers();
+
+ // Have addObserver cause prefHasUserValue to now return true then observe
+ sandbox
+ .stub(global.Services.obs, "addObserver")
+ .callsFake((pref, obs) => {
+ setTimeout(() => {
+ Services.obs.notifyObservers("US", "browser-region-updated");
+ });
+ });
+ });
+ afterEach(() => clock.restore());
+
+ it("should set false with unexpected geo", () => {
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs("browser.search.region")
+ .returns("NOGEO");
+
+ as._updateDynamicPrefs();
+
+ clock.tick(1);
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should set true with expected geo and locale", () => {
+ sandbox
+ .stub(global.NimbusFeatures.pocketNewtab, "getVariable")
+ .withArgs("regionStoriesConfig")
+ .returns("US");
+
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ as._updateDynamicPrefs();
+ clock.tick(1);
+
+ assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should not change default even with expected geo and locale", () => {
+ as._defaultPrefs.set("feeds.system.topstories", false);
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-stories-config"
+ )
+ .returns("US");
+
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ as._updateDynamicPrefs();
+ clock.tick(1);
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should set false with geo blocked", () => {
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-stories-config"
+ )
+ .returns("US")
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-stories-block"
+ )
+ .returns("US");
+
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ as._updateDynamicPrefs();
+ clock.tick(1);
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ });
+ describe("telemetry reporting on init failure", () => {
+ it("should send a ping on init error", () => {
+ as = new ActivityStream();
+ const telemetry = { handleUndesiredEvent: sandbox.spy() };
+ sandbox.stub(as.store, "init").throws();
+ sandbox.stub(as.store.feeds, "get").returns(telemetry);
+ try {
+ as.init();
+ } catch (e) {}
+ assert.calledOnce(telemetry.handleUndesiredEvent);
+ });
+ });
+
+ describe("searchs shortcuts shouldPin pref", () => {
+ const SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF =
+ "improvesearch.topSiteSearchShortcuts.searchEngines";
+ let stub;
+
+ beforeEach(() => {
+ stub = sandbox.stub(global.Region, "home");
+ });
+
+ it("should be an empty string when no geo is available", () => {
+ stub.get(() => "");
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ ""
+ );
+ });
+
+ it("should be 'baidu' in China", () => {
+ stub.get(() => "CN");
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ "baidu"
+ );
+ });
+
+ it("should be 'yandex' in Russia, Belarus, Kazakhstan, and Turkey", () => {
+ const geos = ["BY", "KZ", "RU", "TR"];
+ for (const geo of geos) {
+ stub.get(() => geo);
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ "yandex"
+ );
+ }
+ });
+
+ it("should be 'google,amazon' in Germany, France, the UK, Japan, Italy, and the US", () => {
+ const geos = ["DE", "FR", "GB", "IT", "JP", "US"];
+ for (const geo of geos) {
+ stub.returns(geo);
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ "google,amazon"
+ );
+ }
+ });
+
+ it("should be 'google' elsewhere", () => {
+ // A selection of other geos
+ const geos = ["BR", "CA", "ES", "ID", "IN"];
+ for (const geo of geos) {
+ stub.get(() => geo);
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ "google"
+ );
+ }
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js
new file mode 100644
index 0000000000..b6aeacead2
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js
@@ -0,0 +1,432 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import {
+ ActivityStreamMessageChannel,
+ DEFAULT_OPTIONS,
+} from "lib/ActivityStreamMessageChannel.jsm";
+import { addNumberReducer, GlobalOverrider } from "test/unit/utils";
+import { applyMiddleware, createStore } from "redux";
+
+const OPTIONS = [
+ "pageURL, outgoingMessageName",
+ "incomingMessageName",
+ "dispatch",
+];
+
+// Create an object containing details about a tab as expected within
+// the loaded tabs map in ActivityStreamMessageChannel.jsm.
+function getTabDetails(portID, url = "about:newtab", extraArgs = {}) {
+ let actor = {
+ portID,
+ sendAsyncMessage: sinon.spy(),
+ };
+ let browser = {
+ getAttribute: () => (extraArgs.preloaded ? "preloaded" : ""),
+ ownerGlobal: {},
+ };
+ let browsingContext = {
+ top: {
+ embedderElement: browser,
+ },
+ };
+
+ let data = {
+ data: {
+ actor,
+ browser,
+ browsingContext,
+ portID,
+ url,
+ },
+ target: {
+ browsingContext,
+ },
+ };
+
+ if (extraArgs.loaded) {
+ data.data.loaded = extraArgs.loaded;
+ }
+ if (extraArgs.simulated) {
+ data.data.simulated = extraArgs.simulated;
+ }
+
+ return data;
+}
+
+describe("ActivityStreamMessageChannel", () => {
+ let globals;
+ let dispatch;
+ let mm;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ globals.set("AboutNewTab", {
+ reset: globals.sandbox.spy(),
+ });
+ globals.set("AboutHomeStartupCache", { onPreloadedNewTabMessage() {} });
+ dispatch = globals.sandbox.spy();
+ mm = new ActivityStreamMessageChannel({ dispatch });
+
+ assert.ok(mm.loadedTabs, []);
+
+ let loadedTabs = new Map();
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(mm, "loadedTabs").get(() => loadedTabs);
+ });
+
+ afterEach(() => globals.restore());
+
+ describe("portID validation", () => {
+ let sandbox;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ sandbox.spy(global.console, "error");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should log errors for an invalid portID", () => {
+ mm.validatePortID({});
+ mm.validatePortID({});
+ mm.validatePortID({});
+
+ assert.equal(global.console.error.callCount, 3);
+ });
+ });
+
+ it("should exist", () => {
+ assert.ok(ActivityStreamMessageChannel);
+ });
+ it("should apply default options", () => {
+ mm = new ActivityStreamMessageChannel();
+ OPTIONS.forEach(o => assert.equal(mm[o], DEFAULT_OPTIONS[o], o));
+ });
+ it("should add options", () => {
+ const options = {
+ dispatch: () => {},
+ pageURL: "FOO.html",
+ outgoingMessageName: "OUT",
+ incomingMessageName: "IN",
+ };
+ mm = new ActivityStreamMessageChannel(options);
+ OPTIONS.forEach(o => assert.equal(mm[o], options[o], o));
+ });
+ it("should throw an error if no dispatcher was provided", () => {
+ mm = new ActivityStreamMessageChannel();
+ assert.throws(() => mm.dispatch({ type: "FOO" }));
+ });
+ describe("Creating/destroying the channel", () => {
+ describe("#simulateMessagesForExistingTabs", () => {
+ beforeEach(() => {
+ sinon.stub(mm, "onActionFromContent");
+ });
+ it("should simulate init for existing ports", () => {
+ let msg1 = getTabDetails("inited", "about:monkeys", {
+ simulated: true,
+ });
+ mm.loadedTabs.set(msg1.data.browser, msg1.data);
+
+ let msg2 = getTabDetails("loaded", "about:sheep", {
+ simulated: true,
+ });
+ mm.loadedTabs.set(msg2.data.browser, msg2.data);
+
+ mm.simulateMessagesForExistingTabs();
+
+ assert.calledWith(mm.onActionFromContent.firstCall, {
+ type: at.NEW_TAB_INIT,
+ data: msg1.data,
+ });
+ assert.calledWith(mm.onActionFromContent.secondCall, {
+ type: at.NEW_TAB_INIT,
+ data: msg2.data,
+ });
+ });
+ it("should simulate load for loaded ports", () => {
+ let msg3 = getTabDetails("foo", null, {
+ preloaded: true,
+ loaded: true,
+ });
+ mm.loadedTabs.set(msg3.data.browser, msg3.data);
+
+ mm.simulateMessagesForExistingTabs();
+
+ assert.calledWith(
+ mm.onActionFromContent,
+ { type: at.NEW_TAB_LOAD },
+ "foo"
+ );
+ });
+ it("should set renderLayers on preloaded browsers after load", () => {
+ let msg4 = getTabDetails("foo", null, {
+ preloaded: true,
+ loaded: true,
+ });
+ msg4.data.browser.ownerGlobal = {
+ STATE_MAXIMIZED: 1,
+ STATE_MINIMIZED: 2,
+ STATE_NORMAL: 3,
+ STATE_FULLSCREEN: 4,
+ windowState: 3,
+ isFullyOccluded: false,
+ };
+ mm.loadedTabs.set(msg4.data.browser, msg4.data);
+ mm.simulateMessagesForExistingTabs();
+ assert.equal(msg4.data.browser.renderLayers, true);
+ });
+ });
+ });
+ describe("Message handling", () => {
+ describe("#getTargetById", () => {
+ it("should get an id if it exists", () => {
+ let msg = getTabDetails("foo:1");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getTargetById("foo:1"), msg.data.actor);
+ });
+ it("should return null if the target doesn't exist", () => {
+ let msg = getTabDetails("foo:2");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getTargetById("bar:3"), null);
+ });
+ });
+ describe("#getPreloadedActors", () => {
+ it("should get a preloaded actor if it exists", () => {
+ let msg = getTabDetails("foo:3", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getPreloadedActors()[0].portID, "foo:3");
+ });
+ it("should get all the preloaded actors across windows if they exist", () => {
+ let msg = getTabDetails("foo:4a", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ msg = getTabDetails("foo:4b", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getPreloadedActors().length, 2);
+ });
+ it("should return null if there is no preloaded actor", () => {
+ let msg = getTabDetails("foo:5");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getPreloadedActors(), null);
+ });
+ });
+ describe("#onNewTabInit", () => {
+ it("should dispatch a NEW_TAB_INIT action", () => {
+ let msg = getTabDetails("foo", "about:monkeys");
+ sinon.stub(mm, "onActionFromContent");
+
+ mm.onNewTabInit(msg, msg.data);
+
+ assert.calledWith(mm.onActionFromContent, {
+ type: at.NEW_TAB_INIT,
+ data: msg.data,
+ });
+ });
+ });
+ describe("#onNewTabLoad", () => {
+ it("should dispatch a NEW_TAB_LOAD action", () => {
+ let msg = getTabDetails("foo", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ sinon.stub(mm, "onActionFromContent");
+ mm.onNewTabLoad({ target: msg.target }, msg.data);
+ assert.calledWith(
+ mm.onActionFromContent,
+ { type: at.NEW_TAB_LOAD },
+ "foo"
+ );
+ });
+ });
+ describe("#onNewTabUnload", () => {
+ it("should dispatch a NEW_TAB_UNLOAD action", () => {
+ let msg = getTabDetails("foo");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ sinon.stub(mm, "onActionFromContent");
+ mm.onNewTabUnload({ target: msg.target }, msg.data);
+ assert.calledWith(
+ mm.onActionFromContent,
+ { type: at.NEW_TAB_UNLOAD },
+ "foo"
+ );
+ });
+ });
+ describe("#onMessage", () => {
+ let sandbox;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ sandbox.spy(global.console, "error");
+ });
+ afterEach(() => sandbox.restore());
+ it("return early when tab details are not present", () => {
+ let msg = getTabDetails("foo");
+ sinon.stub(mm, "onActionFromContent");
+ mm.onMessage(msg, msg.data);
+ assert.notCalled(mm.onActionFromContent);
+ });
+ it("should report an error if the msg.data is missing", () => {
+ let msg = getTabDetails("foo");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ let tabDetails = msg.data;
+ delete msg.data;
+ mm.onMessage(msg, tabDetails);
+ assert.calledOnce(global.console.error);
+ });
+ it("should report an error if the msg.data.type is missing", () => {
+ let msg = getTabDetails("foo");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ msg.data = "foo";
+ mm.onMessage(msg, msg.data);
+ assert.calledOnce(global.console.error);
+ });
+ it("should call onActionFromContent", () => {
+ sinon.stub(mm, "onActionFromContent");
+ let msg = getTabDetails("foo");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ let action = {
+ data: { data: {}, type: "FOO" },
+ target: msg.target,
+ };
+ const expectedAction = {
+ type: action.data.type,
+ data: action.data.data,
+ _target: { browser: msg.data.browser },
+ };
+ mm.onMessage(action, msg.data);
+ assert.calledWith(mm.onActionFromContent, expectedAction, "foo");
+ });
+ });
+ });
+ describe("Sending and broadcasting", () => {
+ describe("#send", () => {
+ it("should send a message on the right port", () => {
+ let msg = getTabDetails("foo:6");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ const action = ac.AlsoToOneContent({ type: "HELLO" }, "foo:6");
+ mm.send(action);
+ assert.calledWith(
+ msg.data.actor.sendAsyncMessage,
+ DEFAULT_OPTIONS.outgoingMessageName,
+ action
+ );
+ });
+ it("should not throw if the target isn't around", () => {
+ // port is not added to the channel
+ const action = ac.AlsoToOneContent({ type: "HELLO" }, "foo:7");
+
+ assert.doesNotThrow(() => mm.send(action));
+ });
+ });
+ describe("#broadcast", () => {
+ it("should send a message on the channel", () => {
+ let msg = getTabDetails("foo:8");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ const action = ac.BroadcastToContent({ type: "HELLO" });
+ mm.broadcast(action);
+ assert.calledWith(
+ msg.data.actor.sendAsyncMessage,
+ DEFAULT_OPTIONS.outgoingMessageName,
+ action
+ );
+ });
+ });
+ describe("#preloaded browser", () => {
+ it("should send the message to the preloaded browser if there's data and a preloaded browser exists", () => {
+ let msg = getTabDetails("foo:9", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ const action = ac.AlsoToPreloaded({ type: "HELLO", data: 10 });
+ mm.sendToPreloaded(action);
+ assert.calledWith(
+ msg.data.actor.sendAsyncMessage,
+ DEFAULT_OPTIONS.outgoingMessageName,
+ action
+ );
+ });
+ it("should send the message to all the preloaded browsers if there's data and they exist", () => {
+ let msg1 = getTabDetails("foo:10a", null, { preloaded: true });
+ mm.loadedTabs.set(msg1.data.browser, msg1.data);
+
+ let msg2 = getTabDetails("foo:10b", null, { preloaded: true });
+ mm.loadedTabs.set(msg2.data.browser, msg2.data);
+
+ mm.sendToPreloaded(ac.AlsoToPreloaded({ type: "HELLO", data: 10 }));
+ assert.calledOnce(msg1.data.actor.sendAsyncMessage);
+ assert.calledOnce(msg2.data.actor.sendAsyncMessage);
+ });
+ it("should not send the message to the preloaded browser if there's no data and a preloaded browser does not exists", () => {
+ let msg = getTabDetails("foo:11");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ const action = ac.AlsoToPreloaded({ type: "HELLO" });
+ mm.sendToPreloaded(action);
+ assert.notCalled(msg.data.actor.sendAsyncMessage);
+ });
+ });
+ });
+ describe("Handling actions", () => {
+ describe("#onActionFromContent", () => {
+ beforeEach(() => mm.onActionFromContent({ type: "FOO" }, "foo:12"));
+ it("should dispatch a AlsoToMain action", () => {
+ assert.calledOnce(dispatch);
+ const [action] = dispatch.firstCall.args;
+ assert.equal(action.type, "FOO", "action.type");
+ });
+ it("should have the right fromTarget", () => {
+ const [action] = dispatch.firstCall.args;
+ assert.equal(action.meta.fromTarget, "foo:12", "meta.fromTarget");
+ });
+ });
+ describe("#middleware", () => {
+ let store;
+ beforeEach(() => {
+ store = createStore(addNumberReducer, applyMiddleware(mm.middleware));
+ });
+ it("should just call next if no channel is found", () => {
+ store.dispatch({ type: "ADD", data: 10 });
+ assert.equal(store.getState(), 10);
+ });
+ it("should call .send but not affect the main store if an OnlyToOneContent action is dispatched", () => {
+ sinon.stub(mm, "send");
+ const action = ac.OnlyToOneContent({ type: "ADD", data: 10 }, "foo");
+
+ store.dispatch(action);
+
+ assert.calledWith(mm.send, action);
+ assert.equal(store.getState(), 0);
+ });
+ it("should call .send and update the main store if an AlsoToOneContent action is dispatched", () => {
+ sinon.stub(mm, "send");
+ const action = ac.AlsoToOneContent({ type: "ADD", data: 10 }, "foo");
+
+ store.dispatch(action);
+
+ assert.calledWith(mm.send, action);
+ assert.equal(store.getState(), 10);
+ });
+ it("should call .broadcast if the action is BroadcastToContent", () => {
+ sinon.stub(mm, "broadcast");
+ const action = ac.BroadcastToContent({ type: "FOO" });
+
+ store.dispatch(action);
+
+ assert.calledWith(mm.broadcast, action);
+ });
+ it("should call .sendToPreloaded if the action is AlsoToPreloaded", () => {
+ sinon.stub(mm, "sendToPreloaded");
+ const action = ac.AlsoToPreloaded({ type: "FOO" });
+
+ store.dispatch(action);
+
+ assert.calledWith(mm.sendToPreloaded, action);
+ });
+ it("should dispatch other actions normally", () => {
+ sinon.stub(mm, "send");
+ sinon.stub(mm, "broadcast");
+ sinon.stub(mm, "sendToPreloaded");
+
+ store.dispatch({ type: "ADD", data: 1 });
+
+ assert.equal(store.getState(), 1);
+ assert.notCalled(mm.send);
+ assert.notCalled(mm.broadcast);
+ assert.notCalled(mm.sendToPreloaded);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js
new file mode 100644
index 0000000000..ebc9726def
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js
@@ -0,0 +1,113 @@
+import { DefaultPrefs, Prefs } from "lib/ActivityStreamPrefs.jsm";
+
+const TEST_PREF_CONFIG = new Map([
+ ["foo", { value: true }],
+ ["bar", { value: "BAR" }],
+ ["baz", { value: 1 }],
+ ["qux", { value: "foo", value_local_dev: "foofoo" }],
+]);
+
+describe("ActivityStreamPrefs", () => {
+ describe("Prefs", () => {
+ let p;
+ beforeEach(() => {
+ p = new Prefs();
+ });
+ it("should have get, set, and observe methods", () => {
+ assert.property(p, "get");
+ assert.property(p, "set");
+ assert.property(p, "observe");
+ });
+ describe("#observeBranch", () => {
+ let listener;
+ beforeEach(() => {
+ p._prefBranch = { addObserver: sinon.stub() };
+ listener = { onPrefChanged: sinon.stub() };
+ p.observeBranch(listener);
+ });
+ it("should add an observer", () => {
+ assert.calledOnce(p._prefBranch.addObserver);
+ assert.calledWith(p._prefBranch.addObserver, "");
+ });
+ it("should store the listener", () => {
+ assert.equal(p._branchObservers.size, 1);
+ assert.ok(p._branchObservers.has(listener));
+ });
+ it("should call listener's onPrefChanged", () => {
+ p._branchObservers.get(listener)();
+
+ assert.calledOnce(listener.onPrefChanged);
+ });
+ });
+ describe("#ignoreBranch", () => {
+ let listener;
+ beforeEach(() => {
+ p._prefBranch = {
+ addObserver: sinon.stub(),
+ removeObserver: sinon.stub(),
+ };
+ listener = {};
+ p.observeBranch(listener);
+ });
+ it("should remove the observer", () => {
+ p.ignoreBranch(listener);
+
+ assert.calledOnce(p._prefBranch.removeObserver);
+ assert.calledWith(
+ p._prefBranch.removeObserver,
+ p._prefBranch.addObserver.firstCall.args[0]
+ );
+ });
+ it("should remove the listener", () => {
+ assert.equal(p._branchObservers.size, 1);
+
+ p.ignoreBranch(listener);
+
+ assert.equal(p._branchObservers.size, 0);
+ });
+ });
+ });
+
+ describe("DefaultPrefs", () => {
+ describe("#init", () => {
+ let defaultPrefs;
+ let sandbox;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ defaultPrefs = new DefaultPrefs(TEST_PREF_CONFIG);
+ sinon.stub(defaultPrefs, "set");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should initialize a boolean pref", () => {
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "foo", true);
+ });
+ it("should not initialize a pref if a default exists", () => {
+ defaultPrefs.prefs.set("foo", false);
+
+ defaultPrefs.init();
+
+ assert.neverCalledWith(defaultPrefs.set, "foo", true);
+ });
+ it("should initialize a string pref", () => {
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "bar", "BAR");
+ });
+ it("should initialize a integer pref", () => {
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "baz", 1);
+ });
+ it("should initialize a pref with value if Firefox is not a local build", () => {
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "qux", "foo");
+ });
+ it("should initialize a pref with value_local_dev if Firefox is a local build", () => {
+ sandbox.stub(global.AppConstants, "MOZILLA_OFFICIAL").value(false);
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "qux", "foofoo");
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js
new file mode 100644
index 0000000000..f13dfd07ad
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js
@@ -0,0 +1,161 @@
+import { ActivityStreamStorage } from "lib/ActivityStreamStorage.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+let overrider = new GlobalOverrider();
+
+describe("ActivityStreamStorage", () => {
+ let sandbox;
+ let indexedDB;
+ let storage;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ indexedDB = {
+ open: sandbox.stub().resolves({}),
+ deleteDatabase: sandbox.stub().resolves(),
+ };
+ overrider.set({ IndexedDB: indexedDB });
+ storage = new ActivityStreamStorage({
+ storeNames: ["storage_test"],
+ telemetry: { handleUndesiredEvent: sandbox.stub() },
+ });
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should throw if required arguments not provided", () => {
+ assert.throws(() => new ActivityStreamStorage({ telemetry: true }));
+ });
+ describe(".db", () => {
+ it("should not throw an error when accessing db", async () => {
+ assert.ok(storage.db);
+ });
+
+ it("should delete and recreate the db if opening db fails", async () => {
+ const newDb = {};
+ indexedDB.open.onFirstCall().rejects(new Error("fake error"));
+ indexedDB.open.onSecondCall().resolves(newDb);
+
+ const db = await storage.db;
+ assert.calledOnce(indexedDB.deleteDatabase);
+ assert.calledTwice(indexedDB.open);
+ assert.equal(db, newDb);
+ });
+ });
+ describe("#getDbTable", () => {
+ let testStorage;
+ let storeStub;
+ beforeEach(() => {
+ storeStub = {
+ getAll: sandbox.stub().resolves(),
+ get: sandbox.stub().resolves(),
+ put: sandbox.stub().resolves(),
+ };
+ sandbox.stub(storage, "_getStore").resolves(storeStub);
+ testStorage = storage.getDbTable("storage_test");
+ });
+ it("should reverse key value parameters for put", async () => {
+ await testStorage.set("key", "value");
+
+ assert.calledOnce(storeStub.put);
+ assert.calledWith(storeStub.put, "value", "key");
+ });
+ it("should return the correct value for get", async () => {
+ storeStub.get.withArgs("foo").resolves("foo");
+
+ const result = await testStorage.get("foo");
+
+ assert.calledOnce(storeStub.get);
+ assert.equal(result, "foo");
+ });
+ it("should return the correct value for getAll", async () => {
+ storeStub.getAll.resolves(["bar"]);
+
+ const result = await testStorage.getAll();
+
+ assert.calledOnce(storeStub.getAll);
+ assert.deepEqual(result, ["bar"]);
+ });
+ it("should query the correct object store", async () => {
+ await testStorage.get();
+
+ assert.calledOnce(storage._getStore);
+ assert.calledWithExactly(storage._getStore, "storage_test");
+ });
+ it("should throw if table is not found", () => {
+ assert.throws(() => storage.getDbTable("undefined_store"));
+ });
+ });
+ it("should get the correct objectStore when calling _getStore", async () => {
+ const objectStoreStub = sandbox.stub();
+ indexedDB.open.resolves({ objectStore: objectStoreStub });
+
+ await storage._getStore("foo");
+
+ assert.calledOnce(objectStoreStub);
+ assert.calledWithExactly(objectStoreStub, "foo", "readwrite");
+ });
+ it("should create a db with the correct store name", async () => {
+ const dbStub = {
+ createObjectStore: sandbox.stub(),
+ objectStoreNames: { contains: sandbox.stub().returns(false) },
+ };
+ await storage.db;
+
+ // call the cb with a stub
+ indexedDB.open.args[0][2](dbStub);
+
+ assert.calledOnce(dbStub.createObjectStore);
+ assert.calledWithExactly(dbStub.createObjectStore, "storage_test");
+ });
+ it("should handle an array of object store names", async () => {
+ storage = new ActivityStreamStorage({
+ storeNames: ["store1", "store2"],
+ telemetry: {},
+ });
+ const dbStub = {
+ createObjectStore: sandbox.stub(),
+ objectStoreNames: { contains: sandbox.stub().returns(false) },
+ };
+ await storage.db;
+
+ // call the cb with a stub
+ indexedDB.open.args[0][2](dbStub);
+
+ assert.calledTwice(dbStub.createObjectStore);
+ assert.calledWith(dbStub.createObjectStore, "store1");
+ assert.calledWith(dbStub.createObjectStore, "store2");
+ });
+ it("should skip creating existing stores", async () => {
+ storage = new ActivityStreamStorage({
+ storeNames: ["store1", "store2"],
+ telemetry: {},
+ });
+ const dbStub = {
+ createObjectStore: sandbox.stub(),
+ objectStoreNames: { contains: sandbox.stub().returns(true) },
+ };
+ await storage.db;
+
+ // call the cb with a stub
+ indexedDB.open.args[0][2](dbStub);
+
+ assert.notCalled(dbStub.createObjectStore);
+ });
+ describe("#_requestWrapper", () => {
+ it("should return a successful result", async () => {
+ const result = await storage._requestWrapper(() =>
+ Promise.resolve("foo")
+ );
+
+ assert.equal(result, "foo");
+ assert.notCalled(storage.telemetry.handleUndesiredEvent);
+ });
+ it("should report failures", async () => {
+ try {
+ await storage._requestWrapper(() => Promise.reject(new Error()));
+ } catch (e) {
+ assert.calledOnce(storage.telemetry.handleUndesiredEvent);
+ }
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
new file mode 100644
index 0000000000..e91b7fc549
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
@@ -0,0 +1,3581 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+ actionUtils as au,
+} from "common/Actions.sys.mjs";
+import { combineReducers, createStore } from "redux";
+import { GlobalOverrider } from "test/unit/utils";
+import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.jsm";
+import { RecommendationProvider } from "lib/RecommendationProvider.jsm";
+import { reducers } from "common/Reducers.sys.mjs";
+
+import { PersistentCache } from "lib/PersistentCache.sys.mjs";
+import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm";
+
+const CONFIG_PREF_NAME = "discoverystream.config";
+const DUMMY_ENDPOINT = "https://getpocket.cdn.mozilla.net/dummy";
+const ENDPOINTS_PREF_NAME = "discoverystream.endpoints";
+const SPOC_IMPRESSION_TRACKING_PREF = "discoverystream.spoc.impressions";
+const REC_IMPRESSION_TRACKING_PREF = "discoverystream.rec.impressions";
+const THIRTY_MINUTES = 30 * 60 * 1000;
+const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // 1 week
+
+const FAKE_UUID = "{foo-123-foo}";
+
+// eslint-disable-next-line max-statements
+describe("DiscoveryStreamFeed", () => {
+ let feed;
+ let feeds;
+ let recommendationProvider;
+ let sandbox;
+ let fetchStub;
+ let clock;
+ let fakeNewTabUtils;
+ let fakePktApi;
+ let globals;
+
+ const setPref = (name, value) => {
+ const action = {
+ type: at.PREF_CHANGED,
+ data: {
+ name,
+ value: typeof value === "object" ? JSON.stringify(value) : value,
+ },
+ };
+ feed.store.dispatch(action);
+ feed.onAction(action);
+ };
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+
+ // Fetch
+ fetchStub = sandbox.stub(global, "fetch");
+
+ // Time
+ clock = sinon.useFakeTimers();
+
+ globals = new GlobalOverrider();
+ globals.set({
+ gUUIDGenerator: { generateUUID: () => FAKE_UUID },
+ PersistentCache,
+ PersonalityProvider,
+ });
+
+ sandbox
+ .stub(global.Services.prefs, "getBoolPref")
+ .withArgs("browser.newtabpage.activity-stream.discoverystream.enabled")
+ .returns(true);
+
+ recommendationProvider = new RecommendationProvider();
+ recommendationProvider.store = createStore(combineReducers(reducers), {});
+ feeds = {
+ "feeds.recommendationprovider": recommendationProvider,
+ };
+
+ // Feed
+ feed = new DiscoveryStreamFeed();
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: false,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ "discoverystream.spocs.personalized": true,
+ "discoverystream.recs.personalized": true,
+ },
+ },
+ });
+ feed.store.feeds = {
+ get: name => feeds[name],
+ };
+ global.fetch.resetHistory();
+
+ sandbox.stub(feed, "_maybeUpdateCachedData").resolves();
+
+ globals.set("setTimeout", callback => {
+ callback();
+ });
+
+ fakeNewTabUtils = {
+ blockedLinks: {
+ links: [],
+ isBlocked: () => false,
+ },
+ };
+ globals.set("NewTabUtils", fakeNewTabUtils);
+
+ fakePktApi = {
+ isUserLoggedIn: () => false,
+ getRecentSavesCache: () => null,
+ getRecentSaves: () => null,
+ };
+ globals.set("pktApi", fakePktApi);
+ });
+
+ afterEach(() => {
+ clock.restore();
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("#fetchFromEndpoint", () => {
+ beforeEach(() => {
+ feed._prefCache = {
+ config: {
+ api_key_pref: "",
+ },
+ };
+ fetchStub.resolves({
+ json: () => Promise.resolve("hi"),
+ ok: true,
+ });
+ });
+ it("should get a response", async () => {
+ const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.equal(response, "hi");
+ });
+ it("should not send cookies", async () => {
+ await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.propertyVal(fetchStub.firstCall.args[1], "credentials", "omit");
+ });
+ it("should allow unexpected response", async () => {
+ fetchStub.resolves({ ok: false });
+
+ const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.equal(response, null);
+ });
+ it("should disallow unexpected endpoints", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { [ENDPOINTS_PREF_NAME]: "https://other.site" } },
+ });
+
+ const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.equal(response, null);
+ });
+ it("should allow multiple endpoints", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [ENDPOINTS_PREF_NAME]: `https://other.site,${DUMMY_ENDPOINT}`,
+ },
+ },
+ });
+
+ const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.equal(response, "hi");
+ });
+ it("should replace urls with $apiKey", async () => {
+ sandbox.stub(global.Services.prefs, "getCharPref").returns("replaced");
+
+ await feed.fetchFromEndpoint(
+ "https://getpocket.cdn.mozilla.net/dummy?consumer_key=$apiKey"
+ );
+
+ assert.calledWithMatch(
+ fetchStub,
+ "https://getpocket.cdn.mozilla.net/dummy?consumer_key=replaced",
+ { credentials: "omit" }
+ );
+ });
+ it("should replace locales with $locale", async () => {
+ feed.locale = "replaced";
+ await feed.fetchFromEndpoint(
+ "https://getpocket.cdn.mozilla.net/dummy?locale_lang=$locale"
+ );
+
+ assert.calledWithMatch(
+ fetchStub,
+ "https://getpocket.cdn.mozilla.net/dummy?locale_lang=replaced",
+ { credentials: "omit" }
+ );
+ });
+ it("should allow POST and with other options", async () => {
+ await feed.fetchFromEndpoint("https://getpocket.cdn.mozilla.net/dummy", {
+ method: "POST",
+ body: "{}",
+ });
+
+ assert.calledWithMatch(
+ fetchStub,
+ "https://getpocket.cdn.mozilla.net/dummy",
+ {
+ credentials: "omit",
+ method: "POST",
+ body: "{}",
+ }
+ );
+ });
+ });
+
+ describe("#setupPocketState", () => {
+ it("should setup logged in state and recent saves with cache", async () => {
+ fakePktApi.isUserLoggedIn = () => true;
+ fakePktApi.getRecentSavesCache = () => [1, 2, 3];
+ sandbox.spy(feed.store, "dispatch");
+ await feed.setupPocketState({});
+ assert.calledTwice(feed.store.dispatch);
+ assert.calledWith(
+ feed.store.dispatch.firstCall,
+ ac.OnlyToOneContent(
+ {
+ type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
+ data: { isUserLoggedIn: true },
+ },
+ {}
+ )
+ );
+ assert.calledWith(
+ feed.store.dispatch.secondCall,
+ ac.OnlyToOneContent(
+ {
+ type: at.DISCOVERY_STREAM_RECENT_SAVES,
+ data: { recentSaves: [1, 2, 3] },
+ },
+ {}
+ )
+ );
+ });
+ it("should setup logged in state and recent saves without cache", async () => {
+ fakePktApi.isUserLoggedIn = () => true;
+ fakePktApi.getRecentSaves = ({ success }) => success([1, 2, 3]);
+ sandbox.spy(feed.store, "dispatch");
+ await feed.setupPocketState({});
+ assert.calledTwice(feed.store.dispatch);
+ assert.calledWith(
+ feed.store.dispatch.firstCall,
+ ac.OnlyToOneContent(
+ {
+ type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
+ data: { isUserLoggedIn: true },
+ },
+ {}
+ )
+ );
+ assert.calledWith(
+ feed.store.dispatch.secondCall,
+ ac.OnlyToOneContent(
+ {
+ type: at.DISCOVERY_STREAM_RECENT_SAVES,
+ data: { recentSaves: [1, 2, 3] },
+ },
+ {}
+ )
+ );
+ });
+ });
+
+ describe("#getOrCreateImpressionId", () => {
+ it("should create impression id in constructor", async () => {
+ assert.equal(feed._impressionId, FAKE_UUID);
+ });
+ it("should create impression id if none exists", async () => {
+ sandbox.stub(global.Services.prefs, "getCharPref").returns("");
+ sandbox.stub(global.Services.prefs, "setCharPref").returns();
+
+ const result = feed.getOrCreateImpressionId();
+
+ assert.equal(result, FAKE_UUID);
+ assert.calledOnce(global.Services.prefs.setCharPref);
+ });
+ it("should use impression id if exists", async () => {
+ sandbox.stub(global.Services.prefs, "getCharPref").returns("from get");
+
+ const result = feed.getOrCreateImpressionId();
+
+ assert.equal(result, "from get");
+ assert.calledOnce(global.Services.prefs.getCharPref);
+ });
+ });
+
+ describe("#parseGridPositions", () => {
+ it("should return an equivalent array for an array of non negative integers", async () => {
+ assert.deepEqual(feed.parseGridPositions([0, 2, 3]), [0, 2, 3]);
+ });
+ it("should return undefined for an array containing negative integers", async () => {
+ assert.equal(feed.parseGridPositions([-2, 2, 3]), undefined);
+ });
+ it("should return undefined for an undefined input", async () => {
+ assert.equal(feed.parseGridPositions(undefined), undefined);
+ });
+ });
+
+ describe("#loadLayout", () => {
+ it("should fetch data and populate the cache if it is empty", async () => {
+ const resp = { layout: ["foo", "bar"] };
+ const fakeCache = {};
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.calledOnce(fetchStub);
+ assert.equal(feed.cache.set.firstCall.args[0], "layout");
+ assert.deepEqual(feed.cache.set.firstCall.args[1].layout, resp.layout);
+ });
+ it("should fetch data and populate the cache if the cached data is older than 30 mins", async () => {
+ const resp = { layout: ["foo", "bar"] };
+ const fakeCache = {
+ layout: { layout: ["hello"], lastUpdated: Date.now() },
+ };
+
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) });
+
+ clock.tick(THIRTY_MINUTES + 1);
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.calledOnce(fetchStub);
+ assert.equal(feed.cache.set.firstCall.args[0], "layout");
+ assert.deepEqual(feed.cache.set.firstCall.args[1].layout, resp.layout);
+ });
+ it("should use the cached data and not fetch if the cached data is less than 30 mins old", async () => {
+ const fakeCache = {
+ layout: { layout: ["hello"], lastUpdated: Date.now() },
+ };
+
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ clock.tick(THIRTY_MINUTES - 1);
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.notCalled(fetchStub);
+ assert.notCalled(feed.cache.set);
+ });
+ it("should set spocs_endpoint from layout", async () => {
+ const resp = { layout: ["foo", "bar"], spocs: { url: "foo.com" } };
+ const fakeCache = {};
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "foo.com"
+ );
+ });
+ it("should use local layout with hardcoded_layout being true", async () => {
+ feed.config.hardcoded_layout = true;
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.notCalled(feed.fetchLayout);
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs"
+ );
+ });
+ it("should use local basic layout with hardcoded_layout and hardcoded_basic_layout being true", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.config.hardcoded_basic_layout = true;
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.notCalled(feed.fetchLayout);
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs"
+ );
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 3);
+ });
+ it("should use 1 row layout if specified", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: true,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "discoverystream.region-basic-layout": true,
+ },
+ },
+ });
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 3);
+ });
+ it("should use 7 row layout if specified", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: true,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "discoverystream.region-basic-layout": false,
+ },
+ },
+ });
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 21);
+ });
+ it("should use new spocs endpoint if in the config", async () => {
+ feed.config.spocs_endpoint = "https://spocs.getpocket.com/spocs2";
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs2"
+ );
+ });
+ it("should use local basic layout with hardcoded_layout and FF pref hardcoded_basic_layout", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: false,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "discoverystream.hardcoded-basic-layout": true,
+ },
+ },
+ });
+
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.notCalled(feed.fetchLayout);
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs"
+ );
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 3);
+ });
+ it("should use new spocs endpoint if in a FF pref", async () => {
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: false,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "discoverystream.spocs-endpoint":
+ "https://spocs.getpocket.com/spocs2",
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs2"
+ );
+ });
+ it("should fetch local layout for invalid layout endpoint or when fetch layout fails", async () => {
+ feed.config.hardcoded_layout = false;
+ fetchStub.resolves({ ok: false });
+
+ await feed.loadLayout(feed.store.dispatch, true);
+
+ assert.calledOnce(fetchStub);
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs"
+ );
+ });
+ it("should return enough stories to fill a four card layout", async () => {
+ feed.config.hardcoded_layout = true;
+
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: { fourCardLayout: true },
+ },
+ },
+ });
+
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 24);
+ });
+ it("should create a layout with spoc and widget positions", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocPositions: "1, 2",
+ widgetPositions: "3, 4",
+ },
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.deepEqual(layout[0].components[2].spocs.positions, [
+ { index: 1 },
+ { index: 2 },
+ ]);
+ assert.deepEqual(layout[0].components[2].widgets.positions, [
+ { index: 3 },
+ { index: 4 },
+ ]);
+ });
+ it("should create a layout with spoc position data", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocAdTypes: "1230",
+ spocZoneIds: "4560, 7890",
+ },
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.deepEqual(layout[0].components[2].placement.ad_types, [1230]);
+ assert.deepEqual(
+ layout[0].components[2].placement.zone_ids,
+ [4560, 7890]
+ );
+ });
+ it("should create a layout with spoc topsite position data", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocTopsitesAdTypes: "1230",
+ spocTopsitesZoneIds: "4560, 7890",
+ },
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.deepEqual(layout[0].components[0].placement.ad_types, [1230]);
+ assert.deepEqual(
+ layout[0].components[0].placement.zone_ids,
+ [4560, 7890]
+ );
+ });
+ it("should create a layout with proper spoc url with a site id", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocSiteId: "1234",
+ },
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+ const { spocs } = feed.store.getState().DiscoveryStream;
+ assert.deepEqual(
+ spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs?site=1234"
+ );
+ });
+ });
+
+ describe("#updatePlacements", () => {
+ it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: true } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+ const fakeComponents = {
+ components: [
+ { placement: { name: "first" }, spocs: {} },
+ { placement: { name: "second" }, spocs: {} },
+ ],
+ };
+ const fakeLayout = [fakeComponents];
+
+ feed.updatePlacements(feed.store.dispatch, fakeLayout);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS",
+ data: { placements: [{ name: "first" }, { name: "second" }] },
+ meta: { isStartup: false },
+ });
+ });
+ it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS with prefs array", () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: true, withPref: true } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+ const fakeComponents = {
+ components: [
+ { placement: { name: "withPref" }, spocs: { prefs: ["withPref"] } },
+ { placement: { name: "withoutPref1" }, spocs: {} },
+ {
+ placement: { name: "withoutPref2" },
+ spocs: { prefs: ["whatever"] },
+ },
+ { placement: { name: "withoutPref3" }, spocs: { prefs: [] } },
+ ],
+ };
+ const fakeLayout = [fakeComponents];
+
+ feed.updatePlacements(feed.store.dispatch, fakeLayout);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS",
+ data: { placements: [{ name: "withPref" }, { name: "withoutPref1" }] },
+ meta: { isStartup: false },
+ });
+ });
+ it("should fire update placements from loadLayout", async () => {
+ sandbox.spy(feed, "updatePlacements");
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.calledOnce(feed.updatePlacements);
+ });
+ });
+
+ describe("#placementsForEach", () => {
+ it("should forEach through placements", () => {
+ feed.store.getState = () => ({
+ DiscoveryStream: {
+ spocs: {
+ placements: [{ name: "first" }, { name: "second" }],
+ },
+ },
+ });
+
+ let items = [];
+
+ feed.placementsForEach(item => items.push(item.name));
+
+ assert.deepEqual(items, ["first", "second"]);
+ });
+ });
+
+ describe("#loadLayoutEndPointUsingPref", () => {
+ it("should return endpoint if valid key", async () => {
+ const endpoint = feed.finalLayoutEndpoint(
+ "https://somedomain.org/stories?consumer_key=$apiKey",
+ "test_key_val"
+ );
+ assert.equal(
+ "https://somedomain.org/stories?consumer_key=test_key_val",
+ endpoint
+ );
+ });
+
+ it("should throw error if key is empty", async () => {
+ assert.throws(() => {
+ feed.finalLayoutEndpoint(
+ "https://somedomain.org/stories?consumer_key=$apiKey",
+ ""
+ );
+ });
+ });
+
+ it("should return url if $apiKey is missing in layout_endpoint", async () => {
+ const endpoint = feed.finalLayoutEndpoint(
+ "https://somedomain.org/stories?consumer_key=",
+ "test_key_val"
+ );
+ assert.equal("https://somedomain.org/stories?consumer_key=", endpoint);
+ });
+
+ it("should update config layout_endpoint based on api_key_pref value", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ api_key_pref: "test_api_key_pref",
+ enabled: true,
+ layout_endpoint:
+ "https://somedomain.org/stories?consumer_key=$apiKey",
+ }),
+ },
+ },
+ });
+ sandbox
+ .stub(global.Services.prefs, "getCharPref")
+ .returns("test_api_key_val");
+ assert.equal(
+ "https://somedomain.org/stories?consumer_key=test_api_key_val",
+ feed.config.layout_endpoint
+ );
+ });
+
+ it("should not update config layout_endpoint if api_key_pref missing", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: true,
+ layout_endpoint:
+ "https://somedomain.org/stories?consumer_key=1234",
+ }),
+ },
+ },
+ });
+ sandbox
+ .stub(global.Services.prefs, "getCharPref")
+ .returns("test_api_key_val");
+ assert.notCalled(global.Services.prefs.getCharPref);
+ assert.equal(
+ "https://somedomain.org/stories?consumer_key=1234",
+ feed.config.layout_endpoint
+ );
+ });
+
+ it("should not set config layout_endpoint if layout_endpoint missing in prefs", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: true,
+ }),
+ },
+ },
+ });
+ sandbox
+ .stub(global.Services.prefs, "getCharPref")
+ .returns("test_api_key_val");
+ assert.notCalled(global.Services.prefs.getCharPref);
+ assert.isUndefined(feed.config.layout_endpoint);
+ });
+ });
+
+ describe("#loadComponentFeeds", () => {
+ let fakeCache;
+ let fakeDiscoveryStream;
+ beforeEach(() => {
+ fakeDiscoveryStream = {
+ Prefs: {},
+ DiscoveryStream: {
+ layout: [
+ { components: [{ feed: { url: "foo.com" } }] },
+ { components: [{}] },
+ {},
+ ],
+ },
+ };
+ fakeCache = {};
+ sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should not dispatch updates when layout is not defined", async () => {
+ fakeDiscoveryStream = {
+ DiscoveryStream: {},
+ };
+ feed.store.getState.returns(fakeDiscoveryStream);
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.loadComponentFeeds(feed.store.dispatch);
+
+ assert.notCalled(feed.store.dispatch);
+ });
+
+ it("should populate feeds cache", async () => {
+ fakeCache = {
+ feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
+ };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+
+ await feed.loadComponentFeeds(feed.store.dispatch);
+
+ assert.calledWith(feed.cache.set, "feeds", {
+ "foo.com": { data: "data", lastUpdated: 0 },
+ });
+ });
+
+ it("should send feed update events with new feed data", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.spy(feed.store, "dispatch");
+ feed._prefCache = {
+ config: {
+ api_key_pref: "",
+ },
+ };
+
+ await feed.loadComponentFeeds(feed.store.dispatch);
+
+ assert.calledWith(feed.store.dispatch.firstCall, {
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { status: "failed" } }, url: "foo.com" },
+ meta: { isStartup: false },
+ });
+ assert.calledWith(feed.store.dispatch.secondCall, {
+ type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
+ meta: { isStartup: false },
+ });
+ });
+
+ it("should return number of promises equal to unique urls", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(global.Promise, "all").resolves();
+ fakeDiscoveryStream = {
+ DiscoveryStream: {
+ layout: [
+ {
+ components: [
+ { feed: { url: "foo.com" } },
+ { feed: { url: "bar.com" } },
+ ],
+ },
+ { components: [{ feed: { url: "foo.com" } }] },
+ {},
+ { components: [{ feed: { url: "baz.com" } }] },
+ ],
+ },
+ };
+ feed.store.getState.returns(fakeDiscoveryStream);
+
+ await feed.loadComponentFeeds(feed.store.dispatch);
+
+ assert.calledOnce(global.Promise.all);
+ const { args } = global.Promise.all.firstCall;
+ assert.equal(args[0].length, 3);
+ });
+ });
+
+ describe("#getComponentFeed", () => {
+ it("should fetch fresh feed data if cache is empty", async () => {
+ const fakeCache = {};
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed, "rotate").callsFake(val => val);
+ sandbox
+ .stub(feed, "scoreItems")
+ .callsFake(val => ({ data: val, filtered: [] }));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ recommendations: "data",
+ settings: {
+ recsExpireTime: 1,
+ },
+ });
+
+ const feedResp = await feed.getComponentFeed("foo.com");
+
+ assert.equal(feedResp.data.recommendations, "data");
+ });
+ it("should fetch fresh feed data if cache is old", async () => {
+ const fakeCache = { feeds: { "foo.com": { lastUpdated: Date.now() } } };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ recommendations: "data",
+ settings: {
+ recsExpireTime: 1,
+ },
+ });
+ sandbox.stub(feed, "rotate").callsFake(val => val);
+ sandbox
+ .stub(feed, "scoreItems")
+ .callsFake(val => ({ data: val, filtered: [] }));
+ clock.tick(THIRTY_MINUTES + 1);
+
+ const feedResp = await feed.getComponentFeed("foo.com");
+
+ assert.equal(feedResp.data.recommendations, "data");
+ });
+ it("should return feed data from cache if it is fresh", async () => {
+ const fakeCache = {
+ feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
+ };
+ sandbox.stub(feed.cache, "get").resolves(fakeCache);
+ sandbox.stub(feed, "fetchFromEndpoint").resolves("old data");
+ clock.tick(THIRTY_MINUTES - 1);
+
+ const feedResp = await feed.getComponentFeed("foo.com");
+
+ assert.equal(feedResp.data, "data");
+ });
+ it("should return null if no response was received", async () => {
+ sandbox.stub(feed, "fetchFromEndpoint").resolves(null);
+
+ const feedResp = await feed.getComponentFeed("foo.com");
+
+ assert.deepEqual(feedResp, { data: { status: "failed" } });
+ });
+ });
+
+ describe("#personalizationOverride", () => {
+ it("should dispatch setPref", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.personalization.enabled": true,
+ },
+ },
+ });
+
+ feed.personalizationOverride(true);
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: "discoverystream.personalization.override",
+ value: true,
+ },
+ type: at.SET_PREF,
+ });
+ });
+ it("should dispatch CLEAR_PREF", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.personalization.enabled": true,
+ "discoverystream.personalization.override": true,
+ },
+ },
+ });
+
+ feed.personalizationOverride(false);
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: "discoverystream.personalization.override",
+ },
+ type: at.CLEAR_PREF,
+ });
+ });
+ });
+
+ describe("#loadSpocs", () => {
+ beforeEach(() => {
+ feed._prefCache = {
+ config: {
+ api_key_pref: "",
+ },
+ };
+
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ });
+ it("should not fetch or update cache if no spocs endpoint is defined", async () => {
+ feed.store.dispatch(
+ ac.BroadcastToContent({
+ type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
+ data: "",
+ })
+ );
+
+ sandbox.spy(feed.cache, "set");
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.notCalled(global.fetch);
+ assert.notCalled(feed.cache.set);
+ });
+ it("should fetch fresh spocs data if cache is empty", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "data" });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.calledWith(feed.cache.set, "spocs", {
+ spocs: { placement: "data" },
+ lastUpdated: 0,
+ });
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.data.placement,
+ "data"
+ );
+ });
+ it("should fetch fresh data if cache is old", async () => {
+ const cachedSpoc = {
+ spocs: { placement: "old" },
+ lastUpdated: Date.now(),
+ };
+ const cachedData = { spocs: cachedSpoc };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ clock.tick(THIRTY_MINUTES + 1);
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.data.placement,
+ "new"
+ );
+ });
+ it("should return spoc data from cache if it is fresh", async () => {
+ const cachedSpoc = {
+ spocs: { placement: "old" },
+ lastUpdated: Date.now(),
+ };
+ const cachedData = { spocs: cachedSpoc };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ clock.tick(THIRTY_MINUTES - 1);
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.data.placement,
+ "old"
+ );
+ });
+ it("should properly transform spocs using placements", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox
+ .stub(feed, "fetchFromEndpoint")
+ .resolves({ spocs: { items: [{ id: "data" }] } });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.calledWith(feed.cache.set, "spocs", {
+ spocs: {
+ spocs: {
+ context: "",
+ title: "",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "data", score: 1 }],
+ },
+ },
+ lastUpdated: 0,
+ });
+
+ assert.deepEqual(
+ feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0],
+ { id: "data", score: 1 }
+ );
+ });
+ it("should normalizeSpocsItems for older spoc data", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox
+ .stub(feed, "fetchFromEndpoint")
+ .resolves({ spocs: [{ id: "data" }] });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.deepEqual(
+ feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0],
+ { id: "data", score: 1 }
+ );
+ });
+ it("should call personalizationVersionOverride with feature_flags", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox.stub(feed, "personalizationOverride").returns();
+ sandbox
+ .stub(feed, "fetchFromEndpoint")
+ .resolves({ settings: { feature_flags: {} }, spocs: [{ id: "data" }] });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.calledOnce(feed.personalizationOverride);
+ });
+ it("should return expected data if normalizeSpocsItems returns no spoc data", async () => {
+ // We don't need this for just this test, we are setting placements manually.
+ feed.getPlacements.restore();
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => true,
+ });
+
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox
+ .stub(feed, "fetchFromEndpoint")
+ .resolves({ placement1: [{ id: "data" }], placement2: [] });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ const fakeComponents = {
+ components: [
+ { placement: { name: "placement1" }, spocs: {} },
+ { placement: { name: "placement2" }, spocs: {} },
+ ],
+ };
+ feed.updatePlacements(feed.store.dispatch, [fakeComponents]);
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, {
+ placement1: {
+ title: "",
+ context: "",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "data", score: 1 }],
+ },
+ placement2: {
+ title: "",
+ context: "",
+ items: [],
+ },
+ });
+ });
+ it("should use title and context on spoc data", async () => {
+ // We don't need this for just this test, we are setting placements manually.
+ feed.getPlacements.restore();
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => true,
+ });
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ placement1: {
+ title: "title",
+ context: "context",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "data" }],
+ },
+ });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ const fakeComponents = {
+ components: [{ placement: { name: "placement1" }, spocs: {} }],
+ };
+ feed.updatePlacements(feed.store.dispatch, [fakeComponents]);
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, {
+ placement1: {
+ title: "title",
+ context: "context",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "data", score: 1 }],
+ },
+ });
+ });
+ });
+
+ describe("#normalizeSpocsItems", () => {
+ it("should return correct data if new data passed in", async () => {
+ const spocs = {
+ title: "title",
+ context: "context",
+ sponsor: "sponsor",
+ sponsored_by_override: "override",
+ items: [{ id: "id" }],
+ };
+ const result = feed.normalizeSpocsItems(spocs);
+ assert.deepEqual(result, spocs);
+ });
+ it("should return normalized data if new data passed in without title or context", async () => {
+ const spocs = {
+ items: [{ id: "id" }],
+ };
+ const result = feed.normalizeSpocsItems(spocs);
+ assert.deepEqual(result, {
+ title: "",
+ context: "",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "id" }],
+ });
+ });
+ it("should return normalized data if old data passed in", async () => {
+ const spocs = [{ id: "id" }];
+ const result = feed.normalizeSpocsItems(spocs);
+ assert.deepEqual(result, {
+ title: "",
+ context: "",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "id" }],
+ });
+ });
+ });
+
+ describe("#showSpocs", () => {
+ it("should return true from showSpocs if showSponsoredStories is false", async () => {
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => false,
+ });
+ Object.defineProperty(feed, "showSponsoredTopsites", {
+ get: () => true,
+ });
+ assert.isTrue(feed.showSpocs);
+ });
+ it("should return true from showSpocs if showSponsoredTopsites is false", async () => {
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => true,
+ });
+ Object.defineProperty(feed, "showSponsoredTopsites", {
+ get: () => false,
+ });
+ assert.isTrue(feed.showSpocs);
+ });
+ it("should return true from showSpocs if both are true", async () => {
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => true,
+ });
+ Object.defineProperty(feed, "showSponsoredTopsites", {
+ get: () => true,
+ });
+ assert.isTrue(feed.showSpocs);
+ });
+ it("should return false from showSpocs if both are false", async () => {
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => false,
+ });
+ Object.defineProperty(feed, "showSponsoredTopsites", {
+ get: () => false,
+ });
+ assert.isFalse(feed.showSpocs);
+ });
+ });
+
+ describe("#showSponsoredStories", () => {
+ it("should return false from showSponsoredStories if user pref showSponsored is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: false } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+
+ assert.isFalse(feed.showSponsoredStories);
+ });
+ it("should return false from showSponsoredStories if DiscoveryStream pref show_spocs is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: true } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: false }),
+ });
+
+ assert.isFalse(feed.showSponsoredStories);
+ });
+ it("should return true from showSponsoredStories if both prefs are true", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: true } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+
+ assert.isTrue(feed.showSponsoredStories);
+ });
+ });
+
+ describe("#showSponsoredTopsites", () => {
+ it("should return false from showSponsoredTopsites if user pref showSponsoredTopSites is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsoredTopSites: false } },
+ DiscoveryStream: {
+ spocs: {
+ placements: [{ name: "sponsored-topsites" }],
+ },
+ },
+ });
+ assert.isFalse(feed.showSponsoredTopsites);
+ });
+ it("should return true from showSponsoredTopsites if user pref showSponsoredTopSites is true", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsoredTopSites: true } },
+ DiscoveryStream: {
+ spocs: {
+ placements: [{ name: "sponsored-topsites" }],
+ },
+ },
+ });
+ assert.isTrue(feed.showSponsoredTopsites);
+ });
+ });
+
+ describe("#showStories", () => {
+ it("should return false from showStories if user pref is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.section.topstories": false,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ assert.isFalse(feed.showStories);
+ });
+ it("should return false from showStories if system pref is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": false,
+ },
+ },
+ });
+ assert.isFalse(feed.showStories);
+ });
+ it("should return true from showStories if both prefs are true", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ assert.isTrue(feed.showStories);
+ });
+ });
+
+ describe("#showTopsites", () => {
+ it("should return false from showTopsites if user pref is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.topsites": false,
+ "feeds.system.topsites": true,
+ },
+ },
+ });
+ assert.isFalse(feed.showTopsites);
+ });
+ it("should return false from showTopsites if system pref is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.topsites": true,
+ "feeds.system.topsites": false,
+ },
+ },
+ });
+ assert.isFalse(feed.showTopsites);
+ });
+ it("should return true from showTopsites if both prefs are true", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.topsites": true,
+ "feeds.system.topsites": true,
+ },
+ },
+ });
+ assert.isTrue(feed.showTopsites);
+ });
+ });
+
+ describe("#clearSpocs", () => {
+ let defaultState;
+ let DiscoveryStream;
+ let Prefs;
+ beforeEach(() => {
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+ DiscoveryStream = {
+ layout: [],
+ spocs: {
+ placements: [{ name: "sponsored-topsites" }],
+ },
+ };
+ Prefs = {
+ values: {
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ "feeds.topsites": true,
+ "feeds.system.topsites": true,
+ showSponsoredTopSites: true,
+ showSponsored: true,
+ },
+ };
+ defaultState = {
+ DiscoveryStream,
+ Prefs,
+ };
+ feed.store.getState = () => defaultState;
+ });
+ it("should not fail with no endpoint", async () => {
+ sandbox.stub(feed.store, "getState").returns({
+ Prefs: {
+ values: { "discoverystream.endpointSpocsClear": null },
+ },
+ });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves(null);
+
+ await feed.clearSpocs();
+
+ assert.notCalled(feed.fetchFromEndpoint);
+ });
+ it("should call DELETE with endpoint", async () => {
+ sandbox.stub(feed.store, "getState").returns({
+ Prefs: {
+ values: {
+ "discoverystream.endpointSpocsClear": "https://spocs/user",
+ },
+ },
+ });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves(null);
+ feed._impressionId = "1234";
+
+ await feed.clearSpocs();
+
+ assert.equal(
+ feed.fetchFromEndpoint.firstCall.args[0],
+ "https://spocs/user"
+ );
+ assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "DELETE");
+ assert.equal(
+ feed.fetchFromEndpoint.firstCall.args[1].body,
+ '{"pocket_id":"1234"}'
+ );
+ });
+ it("should properly call clearSpocs when sponsored content is changed", async () => {
+ sandbox.stub(feed, "clearSpocs").returns(Promise.resolve());
+ //sandbox.stub(feed, "updatePlacements").returns();
+ sandbox.stub(feed, "loadSpocs").returns();
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored" },
+ });
+
+ assert.notCalled(feed.clearSpocs);
+
+ Prefs.values.showSponsoredTopSites = false;
+ Prefs.values.showSponsored = false;
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored" },
+ });
+
+ assert.calledOnce(feed.clearSpocs);
+ });
+ it("should call clearSpocs when top stories and top sites is turned off", async () => {
+ sandbox.stub(feed, "clearSpocs").returns(Promise.resolve());
+ Prefs.values["feeds.section.topstories"] = false;
+ Prefs.values["feeds.topsites"] = false;
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "feeds.section.topstories" },
+ });
+
+ assert.calledOnce(feed.clearSpocs);
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "feeds.topsites" },
+ });
+
+ assert.calledTwice(feed.clearSpocs);
+ });
+ });
+
+ describe("#rotate", () => {
+ it("should move seen first story to the back of the response", () => {
+ const recsExpireTime = 5600;
+ const feedResponse = {
+ recommendations: [
+ {
+ id: "first",
+ },
+ {
+ id: "second",
+ },
+ {
+ id: "third",
+ },
+ {
+ id: "fourth",
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ };
+ const fakeImpressions = {
+ first: Date.now() - recsExpireTime * 1000,
+ third: Date.now(),
+ };
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+
+ const result = feed.rotate(
+ feedResponse.recommendations,
+ feedResponse.settings.recsExpireTime
+ );
+
+ assert.equal(result[3].id, "first");
+ });
+ });
+
+ describe("#reset", () => {
+ it("should fire all reset based functions", async () => {
+ sandbox.stub(global.Services.obs, "removeObserver").returns();
+
+ sandbox.stub(feed, "resetDataPrefs").returns();
+ sandbox.stub(feed, "resetCache").returns(Promise.resolve());
+ sandbox.stub(feed, "resetState").returns();
+
+ feed.loaded = true;
+
+ await feed.reset();
+
+ assert.calledOnce(feed.resetDataPrefs);
+ assert.calledOnce(feed.resetCache);
+ assert.calledOnce(feed.resetState);
+ assert.calledOnce(global.Services.obs.removeObserver);
+ });
+ });
+
+ describe("#resetCache", () => {
+ it("should set .layout, .feeds .spocs and .personalization to {}", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.resetCache();
+
+ assert.callCount(feed.cache.set, 4);
+ const firstCall = feed.cache.set.getCall(0);
+ const secondCall = feed.cache.set.getCall(1);
+ const thirdCall = feed.cache.set.getCall(2);
+ const fourthCall = feed.cache.set.getCall(3);
+ assert.deepEqual(firstCall.args, ["layout", {}]);
+ assert.deepEqual(secondCall.args, ["feeds", {}]);
+ assert.deepEqual(thirdCall.args, ["spocs", {}]);
+ assert.deepEqual(fourthCall.args, ["personalization", {}]);
+ });
+ });
+
+ describe("#scoreItems", () => {
+ it("should return initial data if spocs are empty", async () => {
+ const { data: result } = await feed.scoreItems([]);
+
+ assert.equal(result.length, 0);
+ });
+
+ it("should sort based on item_score", async () => {
+ const { data: result } = await feed.scoreItems([
+ { id: 2, flight_id: 2, item_score: 0.8 },
+ { id: 4, flight_id: 4, item_score: 0.5 },
+ { id: 3, flight_id: 3, item_score: 0.7 },
+ { id: 1, flight_id: 1, item_score: 0.9 },
+ ]);
+
+ assert.deepEqual(result, [
+ { id: 1, flight_id: 1, item_score: 0.9, score: 0.9 },
+ { id: 2, flight_id: 2, item_score: 0.8, score: 0.8 },
+ { id: 3, flight_id: 3, item_score: 0.7, score: 0.7 },
+ { id: 4, flight_id: 4, item_score: 0.5, score: 0.5 },
+ ]);
+ });
+
+ it("should sort based on priority", async () => {
+ const { data: result } = await feed.scoreItems([
+ { id: 6, flight_id: 6, priority: 2, item_score: 0.7 },
+ { id: 2, flight_id: 3, priority: 1, item_score: 0.2 },
+ { id: 4, flight_id: 4, item_score: 0.6 },
+ { id: 5, flight_id: 5, priority: 2, item_score: 0.8 },
+ { id: 3, flight_id: 3, item_score: 0.8 },
+ { id: 1, flight_id: 1, priority: 1, item_score: 0.3 },
+ ]);
+
+ assert.deepEqual(result, [
+ {
+ id: 1,
+ flight_id: 1,
+ priority: 1,
+ score: 0.3,
+ item_score: 0.3,
+ },
+ {
+ id: 2,
+ flight_id: 3,
+ priority: 1,
+ score: 0.2,
+ item_score: 0.2,
+ },
+ {
+ id: 5,
+ flight_id: 5,
+ priority: 2,
+ score: 0.8,
+ item_score: 0.8,
+ },
+ {
+ id: 6,
+ flight_id: 6,
+ priority: 2,
+ score: 0.7,
+ item_score: 0.7,
+ },
+ { id: 3, flight_id: 3, item_score: 0.8, score: 0.8 },
+ { id: 4, flight_id: 4, item_score: 0.6, score: 0.6 },
+ ]);
+ });
+
+ it("should add a score prop to spocs", async () => {
+ const { data: result } = await feed.scoreItems([
+ { flight_id: 1, item_score: 0.9 },
+ ]);
+
+ assert.equal(result[0].score, 0.9);
+ });
+ });
+
+ describe("#filterBlocked", () => {
+ it("should return initial data if spocs are empty", () => {
+ const { data: result } = feed.filterBlocked([]);
+
+ assert.equal(result.length, 0);
+ });
+ it("should return initial data if links are not blocked", () => {
+ const { data: result } = feed.filterBlocked([
+ { url: "https://foo.com" },
+ { url: "test.com" },
+ ]);
+ assert.equal(result.length, 2);
+ });
+ it("should return initial recommendations data if links are not blocked", () => {
+ const { data: result } = feed.filterBlocked([
+ { url: "https://foo.com" },
+ { url: "test.com" },
+ ]);
+ assert.equal(result.length, 2);
+ });
+ it("filterRecommendations based on blockedlist by passing feed data", () => {
+ fakeNewTabUtils.blockedLinks.links = [{ url: "https://foo.com" }];
+ fakeNewTabUtils.blockedLinks.isBlocked = site =>
+ fakeNewTabUtils.blockedLinks.links[0].url === site.url;
+
+ const result = feed.filterRecommendations({
+ lastUpdated: 4,
+ data: {
+ recommendations: [{ url: "https://foo.com" }, { url: "test.com" }],
+ },
+ });
+
+ assert.equal(result.lastUpdated, 4);
+ assert.lengthOf(result.data.recommendations, 1);
+ assert.equal(result.data.recommendations[0].url, "test.com");
+ assert.notInclude(
+ result.data.recommendations,
+ fakeNewTabUtils.blockedLinks.links[0]
+ );
+ });
+ });
+
+ describe("#frequencyCapSpocs", () => {
+ it("should return filtered out spocs based on frequency caps", () => {
+ const fakeSpocs = [
+ {
+ id: 1,
+ flight_id: "seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ {
+ id: 2,
+ flight_id: "not-seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ];
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+
+ const { data: result, filtered } = feed.frequencyCapSpocs(fakeSpocs);
+
+ assert.equal(result.length, 1);
+ assert.equal(result[0].flight_id, "not-seen");
+ assert.deepEqual(filtered, [fakeSpocs[0]]);
+ });
+ it("should return simple structure and do nothing with no spocs", () => {
+ const { data: result, filtered } = feed.frequencyCapSpocs([]);
+
+ assert.equal(result.length, 0);
+ assert.equal(filtered.length, 0);
+ });
+ });
+
+ describe("#migrateFlightId", () => {
+ it("should migrate campaign to flight if no flight exists", () => {
+ const fakeSpocs = [
+ {
+ id: 1,
+ campaign_id: "campaign",
+ caps: {
+ lifetime: 3,
+ campaign: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ];
+ const { data: result } = feed.migrateFlightId(fakeSpocs);
+
+ assert.deepEqual(result[0], {
+ id: 1,
+ flight_id: "campaign",
+ campaign_id: "campaign",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ campaign: {
+ count: 1,
+ period: 1,
+ },
+ },
+ });
+ });
+ it("should not migrate campaign to flight if caps or id don't exist", () => {
+ const fakeSpocs = [{ id: 1 }];
+ const { data: result } = feed.migrateFlightId(fakeSpocs);
+
+ assert.deepEqual(result[0], { id: 1 });
+ });
+ it("should return simple structure and do nothing with no spocs", () => {
+ const { data: result } = feed.migrateFlightId([]);
+
+ assert.equal(result.length, 0);
+ });
+ });
+
+ describe("#isBelowFrequencyCap", () => {
+ it("should return true if there are no flight impressions", () => {
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const fakeSpoc = {
+ flight_id: "not-seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ };
+
+ const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
+
+ assert.isTrue(result);
+ });
+ it("should return true if there are no flight caps", () => {
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const fakeSpoc = {
+ flight_id: "seen",
+ caps: {
+ lifetime: 3,
+ },
+ };
+
+ const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
+
+ assert.isTrue(result);
+ });
+
+ it("should return false if lifetime cap is hit", () => {
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const fakeSpoc = {
+ flight_id: "seen",
+ caps: {
+ lifetime: 1,
+ flight: {
+ count: 3,
+ period: 1,
+ },
+ },
+ };
+
+ const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
+
+ assert.isFalse(result);
+ });
+
+ it("should return false if time based cap is hit", () => {
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const fakeSpoc = {
+ flight_id: "seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ };
+
+ const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
+
+ assert.isFalse(result);
+ });
+ });
+
+ describe("#retryFeed", () => {
+ it("should retry a feed fetch", async () => {
+ sandbox.stub(feed, "getComponentFeed").returns(Promise.resolve({}));
+ sandbox.stub(feed, "filterRecommendations").returns({});
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.retryFeed({ url: "https://feed.com" });
+
+ assert.calledOnce(feed.getComponentFeed);
+ assert.calledOnce(feed.filterRecommendations);
+ assert.calledOnce(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ "DISCOVERY_STREAM_FEED_UPDATE"
+ );
+ assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
+ feed: {},
+ url: "https://feed.com",
+ });
+ });
+ });
+
+ describe("#recordFlightImpression", () => {
+ it("should return false if time based cap is hit", () => {
+ sandbox.stub(feed, "readDataPref").returns({});
+ sandbox.stub(feed, "writeDataPref").returns();
+
+ feed.recordFlightImpression("seen");
+
+ assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, {
+ seen: [0],
+ });
+ });
+ });
+
+ describe("#recordBlockFlightId", () => {
+ it("should call writeDataPref with new flight id added", () => {
+ sandbox.stub(feed, "readDataPref").returns({ 1234: 1 });
+ sandbox.stub(feed, "writeDataPref").returns();
+
+ feed.recordBlockFlightId("5678");
+
+ assert.calledOnce(feed.readDataPref);
+ assert.calledWith(feed.writeDataPref, "discoverystream.flight.blocks", {
+ 1234: 1,
+ 5678: 1,
+ });
+ });
+ });
+
+ describe("#cleanUpFlightImpressionPref", () => {
+ it("should remove flight-3 because it is no longer being used", async () => {
+ const fakeSpocs = {
+ spocs: {
+ items: [
+ {
+ flight_id: "flight-1",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ {
+ flight_id: "flight-2",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ],
+ },
+ };
+ const fakeImpressions = {
+ "flight-2": [Date.now() - 1],
+ "flight-3": [Date.now() - 1],
+ };
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.stub(feed, "writeDataPref").returns();
+
+ feed.cleanUpFlightImpressionPref(fakeSpocs);
+
+ assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, {
+ "flight-2": [-1],
+ });
+ });
+ });
+
+ describe("#recordTopRecImpressions", () => {
+ it("should add a rec id to the rec impression pref", () => {
+ sandbox.stub(feed, "readDataPref").returns({});
+ sandbox.stub(feed, "writeDataPref");
+
+ feed.recordTopRecImpressions("rec");
+
+ assert.calledWith(feed.writeDataPref, REC_IMPRESSION_TRACKING_PREF, {
+ rec: 0,
+ });
+ });
+ it("should not add an impression if it already exists", () => {
+ sandbox.stub(feed, "readDataPref").returns({ rec: 4 });
+ sandbox.stub(feed, "writeDataPref");
+
+ feed.recordTopRecImpressions("rec");
+
+ assert.notCalled(feed.writeDataPref);
+ });
+ });
+
+ describe("#cleanUpTopRecImpressionPref", () => {
+ it("should remove recs no longer being used", () => {
+ const newFeeds = {
+ "https://foo.com": {
+ data: {
+ recommendations: [
+ {
+ id: "rec1",
+ },
+ {
+ id: "rec2",
+ },
+ ],
+ },
+ },
+ "https://bar.com": {
+ data: {
+ recommendations: [
+ {
+ id: "rec3",
+ },
+ {
+ id: "rec4",
+ },
+ ],
+ },
+ },
+ };
+ const fakeImpressions = {
+ rec2: Date.now() - 1,
+ rec3: Date.now() - 1,
+ rec5: Date.now() - 1,
+ };
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.stub(feed, "writeDataPref").returns();
+
+ feed.cleanUpTopRecImpressionPref(newFeeds);
+
+ assert.calledWith(feed.writeDataPref, REC_IMPRESSION_TRACKING_PREF, {
+ rec2: -1,
+ rec3: -1,
+ });
+ });
+ });
+
+ describe("#writeDataPref", () => {
+ it("should call Services.prefs.setStringPref", () => {
+ sandbox.spy(feed.store, "dispatch");
+ const fakeImpressions = {
+ foo: [Date.now() - 1],
+ bar: [Date.now() - 1],
+ };
+
+ feed.writeDataPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions);
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: SPOC_IMPRESSION_TRACKING_PREF,
+ value: JSON.stringify(fakeImpressions),
+ },
+ type: at.SET_PREF,
+ });
+ });
+ });
+
+ describe("#addEndpointQuery", () => {
+ const url = "https://spocs.getpocket.com/spocs";
+
+ it("should return same url with no query", () => {
+ const result = feed.addEndpointQuery(url, "");
+ assert.equal(result, url);
+ });
+
+ it("should add multiple query params to standard url", () => {
+ const params = "?first=first&second=second";
+ const result = feed.addEndpointQuery(url, params);
+ assert.equal(result, url + params);
+ });
+
+ it("should add multiple query params to url with a query already", () => {
+ const params = "first=first&second=second";
+ const initialParams = "?zero=zero";
+ const result = feed.addEndpointQuery(
+ `${url}${initialParams}`,
+ `?${params}`
+ );
+ assert.equal(result, `${url}${initialParams}&${params}`);
+ });
+ });
+
+ describe("#readDataPref", () => {
+ it("should return what's in Services.prefs.getStringPref", () => {
+ const fakeImpressions = {
+ foo: [Date.now() - 1],
+ bar: [Date.now() - 1],
+ };
+ setPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions);
+
+ const result = feed.readDataPref(SPOC_IMPRESSION_TRACKING_PREF);
+
+ assert.deepEqual(result, fakeImpressions);
+ });
+ });
+
+ describe("#setupPrefs", () => {
+ it("should call setupPrefs", async () => {
+ sandbox.spy(feed, "setupPrefs");
+ feed.onAction({
+ type: at.INIT,
+ });
+ assert.calledOnce(feed.setupPrefs);
+ });
+ it("should dispatch to at.DISCOVERY_STREAM_PREFS_SETUP with proper data", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ globals.set("ExperimentAPI", {
+ getExperimentMetaData: () => ({
+ slug: "experimentId",
+ branch: {
+ slug: "branchId",
+ },
+ }),
+ getRolloutMetaData: () => ({}),
+ });
+ global.Services.prefs.getBoolPref
+ .withArgs("extensions.pocket.enabled")
+ .returns(true);
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ region: "CA",
+ pocketConfig: {
+ recentSavesEnabled: true,
+ hideDescriptions: false,
+ hideDescriptionsRegions: "US,CA,GB",
+ compactImages: true,
+ imageGradient: true,
+ newSponsoredLabel: true,
+ titleLines: "1",
+ descLines: "1",
+ readTime: true,
+ saveToPocketCard: false,
+ saveToPocketCardRegions: "US,CA,GB",
+ },
+ },
+ },
+ });
+ feed.setupPrefs();
+ assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
+ utmSource: "pocket-newtab",
+ utmCampaign: "experimentId",
+ utmContent: "branchId",
+ });
+ assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, {
+ recentSavesEnabled: true,
+ pocketButtonEnabled: true,
+ saveToPocketCard: true,
+ hideDescriptions: true,
+ compactImages: true,
+ imageGradient: true,
+ newSponsoredLabel: true,
+ titleLines: "1",
+ descLines: "1",
+ readTime: true,
+ });
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_IMPRESSION_STATS", () => {
+ it("should call recordTopRecImpressions from DISCOVERY_STREAM_IMPRESSION_STATS", async () => {
+ sandbox.stub(feed, "recordTopRecImpressions").returns();
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_IMPRESSION_STATS,
+ data: { tiles: [{ id: "seen" }] },
+ });
+
+ assert.calledWith(feed.recordTopRecImpressions, "seen");
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_SPOC_IMPRESSION", () => {
+ beforeEach(() => {
+ const data = {
+ spocs: {
+ items: [
+ {
+ id: 1,
+ flight_id: "seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ {
+ id: 2,
+ flight_id: "not-seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ],
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns({
+ DiscoveryStream: {
+ spocs: {
+ data,
+ },
+ },
+ });
+ });
+
+ it("should call dispatch to ac.AlsoToPreloaded with filtered spoc data", async () => {
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const result = {
+ spocs: {
+ items: [
+ {
+ id: 2,
+ flight_id: "not-seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ],
+ },
+ };
+ sandbox.stub(feed, "recordFlightImpression").returns();
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
+ data: { flightId: "seen" },
+ });
+
+ assert.deepEqual(
+ feed.store.dispatch.secondCall.args[0].data.spocs,
+ result
+ );
+ });
+ it("should not call dispatch to ac.AlsoToPreloaded if spocs were not changed by frequency capping", async () => {
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ const fakeImpressions = {};
+ sandbox.stub(feed, "recordFlightImpression").returns();
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
+ data: { flight_id: "seen" },
+ });
+
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should attempt feq cap on valid spocs with placements on impression", async () => {
+ sandbox.restore();
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ const fakeImpressions = {};
+ sandbox.stub(feed, "recordFlightImpression").returns();
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.spy(feed.store, "dispatch");
+ sandbox.spy(feed, "frequencyCapSpocs");
+
+ const data = {
+ spocs: {
+ items: [
+ {
+ id: 2,
+ flight_id: "seen-2",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ],
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns({
+ DiscoveryStream: {
+ spocs: {
+ data,
+ placements: [{ name: "spocs" }, { name: "notSpocs" }],
+ },
+ },
+ });
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
+ data: { flight_id: "doesn't matter" },
+ });
+
+ assert.calledOnce(feed.frequencyCapSpocs);
+ assert.calledWith(feed.frequencyCapSpocs, data.spocs.items);
+ });
+ });
+
+ describe("#onAction: PLACES_LINK_BLOCKED", () => {
+ beforeEach(() => {
+ const data = {
+ spocs: {
+ items: [
+ {
+ id: 1,
+ flight_id: "foo",
+ url: "foo.com",
+ },
+ {
+ id: 2,
+ flight_id: "bar",
+ url: "bar.com",
+ },
+ ],
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns({
+ DiscoveryStream: {
+ spocs: {
+ data,
+ placements: [{ name: "spocs" }],
+ },
+ },
+ });
+ });
+
+ it("should call dispatch if found a blocked spoc", async () => {
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.PLACES_LINK_BLOCKED,
+ data: { url: "foo.com" },
+ });
+
+ assert.deepEqual(
+ feed.store.dispatch.firstCall.args[0].data.url,
+ "foo.com"
+ );
+ });
+ it("should dispatch once if the blocked is not a SPOC", async () => {
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.PLACES_LINK_BLOCKED,
+ data: { url: "not_a_spoc.com" },
+ });
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.deepEqual(
+ feed.store.dispatch.firstCall.args[0].data.url,
+ "not_a_spoc.com"
+ );
+ });
+ it("should dispatch a DISCOVERY_STREAM_SPOC_BLOCKED for a blocked spoc", async () => {
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.PLACES_LINK_BLOCKED,
+ data: { url: "foo.com" },
+ });
+
+ assert.equal(
+ feed.store.dispatch.secondCall.args[0].type,
+ "DISCOVERY_STREAM_SPOC_BLOCKED"
+ );
+ });
+ });
+
+ describe("#onAction: BLOCK_URL", () => {
+ it("should call recordBlockFlightId whith BLOCK_URL", async () => {
+ sandbox.stub(feed, "recordBlockFlightId").returns();
+
+ await feed.onAction({
+ type: at.BLOCK_URL,
+ data: [
+ {
+ flight_id: "1234",
+ },
+ ],
+ });
+
+ assert.calledWith(feed.recordBlockFlightId, "1234");
+ });
+ });
+
+ describe("#onAction: INIT", () => {
+ it("should be .loaded=false before initialization", () => {
+ assert.isFalse(feed.loaded);
+ });
+ it("should load data and set .loaded=true if config.enabled is true", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+ sandbox.stub(feed, "loadLayout").returns(Promise.resolve());
+
+ await feed.onAction({ type: at.INIT });
+
+ assert.calledOnce(feed.loadLayout);
+ assert.isTrue(feed.loaded);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_SET_VALUE", async () => {
+ it("should add the new value to the pref without changing the existing values", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ setPref(CONFIG_PREF_NAME, { enabled: true, other: "value" });
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE,
+ data: { name: "layout_endpoint", value: "foo.com" },
+ });
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: CONFIG_PREF_NAME,
+ value: JSON.stringify({
+ enabled: true,
+ other: "value",
+ layout_endpoint: "foo.com",
+ }),
+ },
+ type: at.SET_PREF,
+ });
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_POCKET_STATE_INIT", async () => {
+ it("should call setupPocketState", async () => {
+ sandbox.spy(feed, "setupPocketState");
+ feed.onAction({
+ type: at.DISCOVERY_STREAM_POCKET_STATE_INIT,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(feed.setupPocketState);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET", async () => {
+ it("should call configReset", async () => {
+ sandbox.spy(feed, "configReset");
+ feed.onAction({
+ type: at.DISCOVERY_STREAM_CONFIG_RESET,
+ });
+ assert.calledOnce(feed.configReset);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", async () => {
+ it("Should dispatch CLEAR_PREF with pref name", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS,
+ });
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: CONFIG_PREF_NAME,
+ },
+ type: at.CLEAR_PREF,
+ });
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_RETRY_FEED", async () => {
+ it("should call retryFeed", async () => {
+ sandbox.spy(feed, "retryFeed");
+ feed.onAction({
+ type: at.DISCOVERY_STREAM_RETRY_FEED,
+ data: { feed: { url: "https://feed.com" } },
+ });
+ assert.calledOnce(feed.retryFeed);
+ assert.calledWith(feed.retryFeed, { url: "https://feed.com" });
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => {
+ it("should call this.loadLayout if config.enabled changes to true ", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ // First initialize
+ await feed.onAction({ type: at.INIT });
+ assert.isFalse(feed.loaded);
+
+ // force clear cached pref value
+ feed._prefCache = {};
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ sandbox.stub(feed, "resetCache").returns(Promise.resolve());
+ sandbox.stub(feed, "loadLayout").returns(Promise.resolve());
+ await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
+
+ assert.calledOnce(feed.loadLayout);
+ assert.calledOnce(feed.resetCache);
+ assert.isTrue(feed.loaded);
+ });
+ it("should clear the cache if a config change happens and config.enabled is true", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ // force clear cached pref value
+ feed._prefCache = {};
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ sandbox.stub(feed, "resetCache").returns(Promise.resolve());
+ await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
+
+ assert.calledOnce(feed.resetCache);
+ });
+ it("should dispatch DISCOVERY_STREAM_LAYOUT_RESET from DISCOVERY_STREAM_CONFIG_CHANGE", async () => {
+ sandbox.stub(feed, "resetDataPrefs");
+ sandbox.stub(feed, "resetCache").resolves();
+ sandbox.stub(feed, "enable").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ type: at.DISCOVERY_STREAM_LAYOUT_RESET,
+ });
+ });
+ it("should not call this.loadLayout if config.enabled changes to false", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ // force clear cached pref value
+ feed._prefCache = {};
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+ assert.isTrue(feed.loaded);
+
+ feed._prefCache = {};
+ setPref(CONFIG_PREF_NAME, { enabled: false });
+ sandbox.stub(feed, "resetCache").returns(Promise.resolve());
+ sandbox.stub(feed, "loadLayout").returns(Promise.resolve());
+ await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
+
+ assert.notCalled(feed.loadLayout);
+ assert.calledOnce(feed.resetCache);
+ assert.isFalse(feed.loaded);
+ });
+ });
+
+ describe("#onAction: UNINIT", () => {
+ it("should reset pref cache", async () => {
+ feed._prefCache = { cached: "value" };
+
+ await feed.onAction({ type: at.UNINIT });
+
+ assert.deepEqual(feed._prefCache, {});
+ });
+ });
+
+ describe("#onAction: PREF_CHANGED", () => {
+ it("should update state.DiscoveryStream.config when the pref changes", async () => {
+ setPref(CONFIG_PREF_NAME, {
+ enabled: true,
+ show_spocs: false,
+ layout_endpoint: "foo",
+ });
+
+ assert.deepEqual(feed.store.getState().DiscoveryStream.config, {
+ enabled: true,
+ show_spocs: false,
+ layout_endpoint: "foo",
+ });
+ });
+ it("should fire loadSpocs is showSponsored pref changes", async () => {
+ sandbox.stub(feed, "loadSpocs").returns(Promise.resolve());
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored" },
+ });
+
+ assert.calledOnce(feed.loadSpocs);
+ });
+ it("should fire onPrefChange when pocketConfig pref changes", async () => {
+ sandbox.stub(feed, "onPrefChange").returns(Promise.resolve());
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "pocketConfig", value: false },
+ });
+
+ assert.calledOnce(feed.onPrefChange);
+ });
+ it("should fire onCollectionsChanged when collections pref changes", async () => {
+ sandbox.stub(feed, "onCollectionsChanged").returns(Promise.resolve());
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.sponsored-collections.enabled" },
+ });
+
+ assert.calledOnce(feed.onCollectionsChanged);
+ });
+ it("should re enable stories when top stories is turned on", async () => {
+ sandbox.stub(feed, "refreshAll").returns(Promise.resolve());
+ feed.loaded = true;
+ setPref(CONFIG_PREF_NAME, {
+ enabled: true,
+ });
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "feeds.section.topstories", value: true },
+ });
+
+ assert.calledOnce(feed.refreshAll);
+ });
+ });
+
+ describe("#onAction: SYSTEM_TICK", () => {
+ it("should not refresh if DiscoveryStream has not been loaded", async () => {
+ sandbox.stub(feed, "refreshAll").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.SYSTEM_TICK });
+ assert.notCalled(feed.refreshAll);
+ });
+
+ it("should not refresh if no caches are expired", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+
+ sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(false);
+ sandbox.stub(feed, "refreshAll").resolves();
+
+ await feed.onAction({ type: at.SYSTEM_TICK });
+ assert.notCalled(feed.refreshAll);
+ });
+
+ it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+
+ sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true);
+ sandbox.stub(feed, "refreshAll").resolves();
+
+ await feed.onAction({ type: at.SYSTEM_TICK });
+ assert.calledOnce(feed.refreshAll);
+ });
+
+ it("should refresh and not update open tabs if DiscoveryStream has been loaded at least once", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+
+ sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true);
+ sandbox.stub(feed, "refreshAll").resolves();
+
+ await feed.onAction({ type: at.SYSTEM_TICK });
+ assert.calledWith(feed.refreshAll, { updateOpenTabs: false });
+ });
+ });
+
+ describe("#onCollectionsChanged", () => {
+ it("should call loadLayout when Pocket config changes", async () => {
+ sandbox.stub(feed, "loadLayout").callsFake(dispatch => dispatch("foo"));
+ sandbox.stub(feed.store, "dispatch");
+ await feed.onCollectionsChanged();
+ assert.calledOnce(feed.loadLayout);
+ assert.calledWith(feed.store.dispatch, ac.AlsoToPreloaded("foo"));
+ });
+ });
+
+ describe("#onPrefChange", () => {
+ it("should call loadLayout when Pocket config changes", async () => {
+ sandbox.stub(feed, "loadLayout");
+ feed._prefCache.config = {
+ enabled: true,
+ };
+ await feed.onPrefChange();
+ assert.calledOnce(feed.loadLayout);
+ });
+ });
+
+ describe("#onAction: PREF_SHOW_SPONSORED", () => {
+ it("should call loadSpocs when preference changes", async () => {
+ sandbox.stub(feed, "loadSpocs").resolves();
+ sandbox.stub(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored" },
+ });
+
+ assert.calledOnce(feed.loadSpocs);
+ const [dispatchFn] = feed.loadSpocs.firstCall.args;
+ dispatchFn({});
+ assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({}));
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_DEV_IDLE_DAILY", () => {
+ it("should trigger idle-daily observer", async () => {
+ sandbox.stub(global.Services.obs, "notifyObservers").returns();
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_DEV_IDLE_DAILY,
+ });
+ assert.calledWith(
+ global.Services.obs.notifyObservers,
+ null,
+ "idle-daily"
+ );
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_DEV_SYNC_RS", () => {
+ it("should fire remote settings pollChanges", async () => {
+ sandbox.stub(global.RemoteSettings, "pollChanges").returns();
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_DEV_SYNC_RS,
+ });
+ assert.calledOnce(global.RemoteSettings.pollChanges);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_DEV_SYSTEM_TICK", () => {
+ it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+
+ sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true);
+ sandbox.stub(feed, "refreshAll").resolves();
+
+ await feed.onAction({ type: at.DISCOVERY_STREAM_DEV_SYSTEM_TICK });
+ assert.calledOnce(feed.refreshAll);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_DEV_EXPIRE_CACHE", () => {
+ it("should fire resetCache", async () => {
+ sandbox.stub(feed, "resetContentCache").returns();
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE,
+ });
+ assert.calledOnce(feed.resetContentCache);
+ });
+ });
+
+ describe("#spocsCacheUpdateTime", () => {
+ it("should call setupSpocsCacheUpdateTime", () => {
+ const defaultCacheTime = 30 * 60 * 1000;
+ sandbox.spy(feed, "setupSpocsCacheUpdateTime");
+ const cacheTime = feed.spocsCacheUpdateTime;
+ assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
+ assert.equal(cacheTime, defaultCacheTime);
+ assert.calledOnce(feed.setupSpocsCacheUpdateTime);
+ });
+ it("should return _spocsCacheUpdateTime", () => {
+ sandbox.spy(feed, "setupSpocsCacheUpdateTime");
+ const testCacheTime = 123;
+ feed._spocsCacheUpdateTime = testCacheTime;
+ const cacheTime = feed.spocsCacheUpdateTime;
+ // Ensure _spocsCacheUpdateTime was not changed.
+ assert.equal(feed._spocsCacheUpdateTime, testCacheTime);
+ assert.equal(cacheTime, testCacheTime);
+ assert.notCalled(feed.setupSpocsCacheUpdateTime);
+ });
+ });
+
+ describe("#setupSpocsCacheUpdateTime", () => {
+ it("should set _spocsCacheUpdateTime with default value", () => {
+ const defaultCacheTime = 30 * 60 * 1000;
+ feed.setupSpocsCacheUpdateTime();
+ assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
+ });
+ it("should set _spocsCacheUpdateTime with min", () => {
+ const defaultCacheTime = 30 * 60 * 1000;
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocsCacheTimeout: 1,
+ },
+ },
+ },
+ });
+ feed.setupSpocsCacheUpdateTime();
+ assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
+ });
+ it("should set _spocsCacheUpdateTime with max", () => {
+ const defaultCacheTime = 30 * 60 * 1000;
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocsCacheTimeout: 31,
+ },
+ },
+ },
+ });
+ feed.setupSpocsCacheUpdateTime();
+ assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
+ });
+ it("should set _spocsCacheUpdateTime with spocsCacheTimeout", () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocsCacheTimeout: 20,
+ },
+ },
+ },
+ });
+ feed.setupSpocsCacheUpdateTime();
+ assert.equal(feed._spocsCacheUpdateTime, 20 * 60 * 1000);
+ });
+ });
+
+ describe("#isExpired", () => {
+ it("should throw if the key is not valid", () => {
+ assert.throws(() => {
+ feed.isExpired({}, "foo");
+ });
+ });
+ it("should return false for layout on startup for content under 1 week", () => {
+ const layout = { lastUpdated: Date.now() };
+ const result = feed.isExpired({
+ cachedData: { layout },
+ key: "layout",
+ isStartup: true,
+ });
+
+ assert.isFalse(result);
+ });
+ it("should return true for layout for isStartup=false after 30 mins", () => {
+ const layout = { lastUpdated: Date.now() };
+ clock.tick(THIRTY_MINUTES + 1);
+ const result = feed.isExpired({ cachedData: { layout }, key: "layout" });
+
+ assert.isTrue(result);
+ });
+ it("should return true for layout on startup for content over 1 week", () => {
+ const layout = { lastUpdated: Date.now() };
+ clock.tick(ONE_WEEK + 1);
+ const result = feed.isExpired({
+ cachedData: { layout },
+ key: "layout",
+ isStartup: true,
+ });
+
+ assert.isTrue(result);
+ });
+ it("should return false for hardcoded layout on startup for content over 1 week", () => {
+ feed._prefCache.config = {
+ hardcoded_layout: true,
+ };
+ const layout = { lastUpdated: Date.now() };
+ clock.tick(ONE_WEEK + 1);
+ const result = feed.isExpired({
+ cachedData: { layout },
+ key: "layout",
+ isStartup: true,
+ });
+
+ assert.isFalse(result);
+ });
+ });
+
+ describe("#checkIfAnyCacheExpired", () => {
+ let cache;
+ beforeEach(() => {
+ cache = {
+ layout: { lastUpdated: Date.now() },
+ feeds: { "foo.com": { lastUpdated: Date.now() } },
+ spocs: { lastUpdated: Date.now() },
+ };
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ sandbox.stub(feed.cache, "get").resolves(cache);
+ });
+
+ it("should return false if nothing in the cache is expired", async () => {
+ const result = await feed.checkIfAnyCacheExpired();
+ assert.isFalse(result);
+ });
+
+ it("should return true if .layout is missing", async () => {
+ delete cache.layout;
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ it("should return true if .layout is expired", async () => {
+ clock.tick(THIRTY_MINUTES + 1);
+ // Update other caches we aren't testing
+ cache.feeds["foo.com"].lastUpdate = Date.now();
+ cache.spocs.lastUpdate = Date.now();
+
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+
+ it("should return true if .spocs is missing", async () => {
+ delete cache.spocs;
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ it("should return true if .spocs is expired", async () => {
+ clock.tick(THIRTY_MINUTES + 1);
+ // Update other caches we aren't testing
+ cache.layout.lastUpdated = Date.now();
+ cache.feeds["foo.com"].lastUpdate = Date.now();
+
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+
+ it("should return true if .feeds is missing", async () => {
+ delete cache.feeds;
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ it("should return true if data for .feeds[url] is missing", async () => {
+ cache.feeds["foo.com"] = null;
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ it("should return true if data for .feeds[url] is expired", async () => {
+ clock.tick(THIRTY_MINUTES + 1);
+ // Update other caches we aren't testing
+ cache.layout.lastUpdated = Date.now();
+ cache.spocs.lastUpdate = Date.now();
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ });
+
+ describe("#refreshAll", () => {
+ beforeEach(() => {
+ sandbox.stub(feed, "loadLayout").resolves();
+ sandbox.stub(feed, "loadComponentFeeds").resolves();
+ sandbox.stub(feed, "loadSpocs").resolves();
+ sandbox.spy(feed.store, "dispatch");
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ });
+
+ it("should call layout, component, spocs update and telemetry reporting functions", async () => {
+ await feed.refreshAll();
+
+ assert.calledOnce(feed.loadLayout);
+ assert.calledOnce(feed.loadComponentFeeds);
+ assert.calledOnce(feed.loadSpocs);
+ });
+ it("should pass in dispatch wrapped with broadcast if options.updateOpenTabs is true", async () => {
+ await feed.refreshAll({ updateOpenTabs: true });
+ [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => {
+ assert.calledOnce(fn);
+ const result = fn.firstCall.args[0]({ type: "FOO" });
+ assert.isTrue(au.isBroadcastToContent(result));
+ });
+ });
+ it("should pass in dispatch with regular actions if options.updateOpenTabs is false", async () => {
+ await feed.refreshAll({ updateOpenTabs: false });
+ [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => {
+ assert.calledOnce(fn);
+ const result = fn.firstCall.args[0]({ type: "FOO" });
+ assert.deepEqual(result, { type: "FOO" });
+ });
+ });
+ it("should set loaded to true if loadSpocs and loadComponentFeeds fails", async () => {
+ feed.loadComponentFeeds.rejects("loadComponentFeeds error");
+ feed.loadSpocs.rejects("loadSpocs error");
+
+ await feed.enable();
+
+ assert.isTrue(feed.loaded);
+ });
+ it("should call loadComponentFeeds and loadSpocs in Promise.all", async () => {
+ sandbox.stub(global.Promise, "all").resolves();
+
+ await feed.refreshAll();
+
+ assert.calledOnce(global.Promise.all);
+ const { args } = global.Promise.all.firstCall;
+ assert.equal(args[0].length, 2);
+ });
+ describe("test startup cache behaviour", () => {
+ beforeEach(() => {
+ feed._maybeUpdateCachedData.restore();
+ sandbox.stub(feed.cache, "set").resolves();
+ });
+ it("should refresh layout on startup if it was served from cache", async () => {
+ feed.loadLayout.restore();
+ sandbox
+ .stub(feed.cache, "get")
+ .resolves({ layout: { lastUpdated: Date.now(), layout: {} } });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ layout: {} });
+ clock.tick(THIRTY_MINUTES + 1);
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.calledOnce(feed.fetchFromEndpoint);
+ // Once from cache, once to update the store
+ assert.calledTwice(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.DISCOVERY_STREAM_LAYOUT_UPDATE
+ );
+ });
+ it("should not refresh layout on startup if it is under THIRTY_MINUTES", async () => {
+ feed.loadLayout.restore();
+ sandbox
+ .stub(feed.cache, "get")
+ .resolves({ layout: { lastUpdated: Date.now(), layout: {} } });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ layout: {} });
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.notCalled(feed.fetchFromEndpoint);
+ });
+ it("should refresh spocs on startup if it was served from cache", async () => {
+ feed.loadSpocs.restore();
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ sandbox
+ .stub(feed.cache, "get")
+ .resolves({ spocs: { lastUpdated: Date.now() } });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves("data");
+ clock.tick(THIRTY_MINUTES + 1);
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.calledOnce(feed.fetchFromEndpoint);
+ // Once from cache, once to update the store
+ assert.calledTwice(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.DISCOVERY_STREAM_SPOCS_UPDATE
+ );
+ });
+ it("should not refresh spocs on startup if it is under THIRTY_MINUTES", async () => {
+ feed.loadSpocs.restore();
+ sandbox
+ .stub(feed.cache, "get")
+ .resolves({ spocs: { lastUpdated: Date.now() } });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves("data");
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.notCalled(feed.fetchFromEndpoint);
+ });
+ it("should refresh feeds on startup if it was served from cache", async () => {
+ feed.loadComponentFeeds.restore();
+
+ const fakeComponents = { components: [{ feed: { url: "foo.com" } }] };
+ const fakeLayout = [fakeComponents];
+ const fakeDiscoveryStream = {
+ DiscoveryStream: {
+ layout: fakeLayout,
+ },
+ Prefs: {
+ values: {
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
+ sandbox.stub(feed, "rotate").callsFake(val => val);
+ sandbox
+ .stub(feed, "scoreItems")
+ .callsFake(val => ({ data: val, filtered: [] }));
+ sandbox.stub(feed, "cleanUpTopRecImpressionPref").callsFake(val => val);
+
+ const fakeCache = {
+ feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
+ };
+ sandbox.stub(feed.cache, "get").resolves(fakeCache);
+ clock.tick(THIRTY_MINUTES + 1);
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ recommendations: "data",
+ settings: {
+ recsExpireTime: 1,
+ },
+ });
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.calledOnce(feed.fetchFromEndpoint);
+ // Once from cache, once to update the feed, once to update that all feeds are done.
+ assert.calledThrice(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.secondCall.args[0].type,
+ at.DISCOVERY_STREAM_FEEDS_UPDATE
+ );
+ });
+ });
+ });
+
+ describe("#scoreFeeds", () => {
+ it("should score feeds and set cache, and dispatch", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ sandbox.spy(feed.store, "dispatch");
+ const recsExpireTime = 5600;
+ const fakeImpressions = {
+ first: Date.now() - recsExpireTime * 1000,
+ third: Date.now(),
+ };
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ const fakeFeeds = {
+ data: {
+ "https://foo.com": {
+ data: {
+ recommendations: [
+ {
+ id: "first",
+ item_score: 0.7,
+ },
+ {
+ id: "second",
+ item_score: 0.6,
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ },
+ },
+ "https://bar.com": {
+ data: {
+ recommendations: [
+ {
+ id: "third",
+ item_score: 0.4,
+ },
+ {
+ id: "fourth",
+ item_score: 0.6,
+ },
+ {
+ id: "fifth",
+ item_score: 0.8,
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ },
+ },
+ },
+ };
+ const feedsTestResult = {
+ "https://foo.com": {
+ data: {
+ recommendations: [
+ {
+ id: "second",
+ item_score: 0.6,
+ score: 0.6,
+ },
+ {
+ id: "first",
+ item_score: 0.7,
+ score: 0.7,
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ },
+ },
+ "https://bar.com": {
+ data: {
+ recommendations: [
+ {
+ id: "fifth",
+ item_score: 0.8,
+ score: 0.8,
+ },
+ {
+ id: "fourth",
+ item_score: 0.6,
+ score: 0.6,
+ },
+ {
+ id: "third",
+ item_score: 0.4,
+ score: 0.4,
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ },
+ },
+ };
+
+ await feed.scoreFeeds(fakeFeeds);
+
+ assert.calledWith(feed.cache.set, "feeds", feedsTestResult);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.DISCOVERY_STREAM_FEED_UPDATE
+ );
+ assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
+ url: "https://foo.com",
+ feed: feedsTestResult["https://foo.com"],
+ });
+ assert.equal(
+ feed.store.dispatch.secondCall.args[0].type,
+ at.DISCOVERY_STREAM_FEED_UPDATE
+ );
+ assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, {
+ url: "https://bar.com",
+ feed: feedsTestResult["https://bar.com"],
+ });
+ });
+ });
+
+ describe("#scoreSpocs", () => {
+ it("should score spocs and set cache, dispatch", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ sandbox.spy(feed.store, "dispatch");
+ const fakeDiscoveryStream = {
+ Prefs: {
+ values: {
+ "discoverystream.spocs.personalized": true,
+ "discoverystream.recs.personalized": false,
+ },
+ },
+ DiscoveryStream: {
+ spocs: {
+ placements: [
+ { name: "placement1" },
+ { name: "placement2" },
+ { name: "placement3" },
+ ],
+ },
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
+ const fakeSpocs = {
+ lastUpdated: 1234,
+ data: {
+ placement1: {
+ items: [
+ {
+ item_score: 0.6,
+ },
+ {
+ item_score: 0.4,
+ },
+ {
+ item_score: 0.8,
+ },
+ ],
+ },
+ placement2: {
+ items: [
+ {
+ item_score: 0.6,
+ },
+ {
+ item_score: 0.8,
+ },
+ ],
+ },
+ placement3: { items: [] },
+ },
+ };
+
+ await feed.scoreSpocs(fakeSpocs);
+
+ const spocsTestResult = {
+ lastUpdated: 1234,
+ spocs: {
+ placement1: {
+ items: [
+ {
+ score: 0.8,
+ item_score: 0.8,
+ },
+ {
+ score: 0.6,
+ item_score: 0.6,
+ },
+ {
+ score: 0.4,
+ item_score: 0.4,
+ },
+ ],
+ },
+ placement2: {
+ items: [
+ {
+ score: 0.8,
+ item_score: 0.8,
+ },
+ {
+ score: 0.6,
+ item_score: 0.6,
+ },
+ ],
+ },
+ placement3: { items: [] },
+ },
+ };
+ assert.calledWith(feed.cache.set, "spocs", spocsTestResult);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.DISCOVERY_STREAM_SPOCS_UPDATE
+ );
+ assert.deepEqual(
+ feed.store.dispatch.firstCall.args[0].data,
+ spocsTestResult
+ );
+ });
+ });
+
+ describe("#scoreContent", () => {
+ it("should call scoreFeeds and scoreSpocs if loaded", async () => {
+ const fakeDiscoveryStream = {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ recsPersonalized: true,
+ spocsPersonalized: true,
+ },
+ "discoverystream.personalization.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ DiscoveryStream: {
+ feeds: { loaded: false },
+ spocs: { loaded: false },
+ },
+ };
+
+ sandbox.stub(feed, "scoreFeeds").resolves();
+ sandbox.stub(feed, "scoreSpocs").resolves();
+ sandbox.stub(feed, "refreshContent").resolves();
+ sandbox.stub(feed, "loadPersonalizationScoresCache").resolves();
+ sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
+ sandbox.stub(feed, "_checkExpirationPerComponent").resolves({
+ feeds: true,
+ spocs: true,
+ });
+
+ await feed.refreshAll();
+
+ assert.notCalled(feed.scoreFeeds);
+ assert.notCalled(feed.scoreSpocs);
+
+ fakeDiscoveryStream.DiscoveryStream.feeds.loaded = true;
+ fakeDiscoveryStream.DiscoveryStream.spocs.loaded = true;
+
+ await feed.refreshAll();
+
+ assert.calledOnce(feed.scoreFeeds);
+ assert.calledOnce(feed.scoreSpocs);
+ });
+ });
+
+ describe("#loadPersonalizationScoresCache", () => {
+ it("should create a personalization provider from cached scores", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ recsPersonalized: true,
+ spocsPersonalized: true,
+ },
+ "discoverystream.personalization.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ const fakeCache = {
+ personalization: {
+ scores: 123,
+ _timestamp: 456,
+ },
+ };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+
+ await feed.loadPersonalizationScoresCache();
+
+ assert.equal(feed.personalizationLastUpdated, 456);
+ });
+ });
+
+ describe("#observe", () => {
+ it("should call updatePersonalizationScores on idle daily", async () => {
+ sandbox.stub(feed, "updatePersonalizationScores").returns();
+ feed.observe(null, "idle-daily");
+ assert.calledOnce(feed.updatePersonalizationScores);
+ });
+ it("should call configReset on Pocket button pref change", async () => {
+ sandbox.stub(feed, "configReset").returns();
+ feed.observe(null, "nsPref:changed", "extensions.pocket.enabled");
+ assert.calledOnce(feed.configReset);
+ });
+ });
+
+ describe("#updatePersonalizationScores", () => {
+ it("should update recommendationProvider on updatePersonalizationScores", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ recsPersonalized: true,
+ spocsPersonalized: true,
+ },
+ "discoverystream.personalization.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ sandbox.stub(feed.recommendationProvider, "init").returns();
+
+ await feed.updatePersonalizationScores();
+
+ assert.deepEqual(feed.recommendationProvider.provider.getScores(), {
+ interestConfig: undefined,
+ interestVector: undefined,
+ });
+ });
+ it("should not update recommendationProvider on updatePersonalizationScores", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.spocs.personalized": true,
+ "discoverystream.recs.personalized": true,
+ "discoverystream.personalization.enabled": false,
+ },
+ },
+ });
+ await feed.updatePersonalizationScores();
+
+ assert.isTrue(!feed.recommendationProvider.provider);
+ });
+ });
+ describe("#scoreItem", () => {
+ it("should call calculateItemRelevanceScore with recommendationProvider with initial score", async () => {
+ const item = {
+ item_score: 0.6,
+ };
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ recsPersonalized: true,
+ spocsPersonalized: true,
+ },
+ "discoverystream.personalization.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ feed.recommendationProvider.calculateItemRelevanceScore = sandbox
+ .stub()
+ .returns();
+ const result = await feed.scoreItem(item, true);
+ assert.calledOnce(
+ feed.recommendationProvider.calculateItemRelevanceScore
+ );
+ assert.equal(result.score, 0.6);
+ });
+ it("should fallback to score 1 without an initial score", async () => {
+ const item = {};
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.spocs.personalized": true,
+ "discoverystream.recs.personalized": true,
+ "discoverystream.personalization.enabled": true,
+ },
+ },
+ });
+ feed.recommendationProvider.calculateItemRelevanceScore = sandbox
+ .stub()
+ .returns();
+ const result = await feed.scoreItem(item, true);
+ assert.equal(result.score, 1);
+ });
+ });
+ describe("new proxy feed", () => {
+ beforeEach(() => {
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: { regionBffConfig: "DE" },
+ },
+ },
+ });
+ sandbox.stub(global.Region, "home").get(() => "DE");
+ globals.set("NimbusFeatures", {
+ saveToPocket: {
+ getVariable: sandbox.stub(),
+ },
+ });
+ global.NimbusFeatures.saveToPocket.getVariable
+ .withArgs("bffApi")
+ .returns("bffApi");
+ global.NimbusFeatures.saveToPocket.getVariable
+ .withArgs("oAuthConsumerKeyBff")
+ .returns("oAuthConsumerKeyBff");
+ });
+ it("should return true with isBff", async () => {
+ assert.isUndefined(feed._isBff);
+ assert.isTrue(feed.isBff);
+ assert.isTrue(feed._isBff);
+ });
+ it("should update to new feed url", async () => {
+ await feed.loadLayout(feed.store.dispatch);
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(
+ layout[0].components[2].feed.url,
+ "https://bffApi/desktop/v1/recommendations?locale=$locale&region=$region&count=30"
+ );
+ });
+ it("should fetch proper data from getComponentFeed", async () => {
+ const fakeCache = {};
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed, "rotate").callsFake(val => val);
+ sandbox
+ .stub(feed, "scoreItems")
+ .callsFake(val => ({ data: val, filtered: [] }));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ data: [
+ {
+ tileId: 1234,
+ url: "url",
+ title: "title",
+ excerpt: "excerpt",
+ publisher: "publisher",
+ imageUrl: "imageUrl",
+ },
+ ],
+ });
+
+ const feedData = await feed.getComponentFeed("url");
+ assert.deepEqual(feedData, {
+ lastUpdated: 0,
+ data: {
+ settings: {},
+ recommendations: [
+ {
+ id: 1234,
+ url: "url",
+ title: "title",
+ excerpt: "excerpt",
+ publisher: "publisher",
+ raw_image_src: "imageUrl",
+ },
+ ],
+ status: "success",
+ },
+ });
+ assert.equal(feed.fetchFromEndpoint.firstCall.args[0], "url");
+ assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "GET");
+ assert.equal(
+ feed.fetchFromEndpoint.firstCall.args[1].headers.get("consumer_key"),
+ "oAuthConsumerKeyBff"
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/DownloadsManager.test.js b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js
new file mode 100644
index 0000000000..0dfdff548b
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js
@@ -0,0 +1,373 @@
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { DownloadsManager } from "lib/DownloadsManager.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("Downloads Manager", () => {
+ let downloadsManager;
+ let globals;
+ const DOWNLOAD_URL = "https://site.com/download.mov";
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ global.Cc["@mozilla.org/timer;1"] = {
+ createInstance() {
+ return {
+ initWithCallback: sinon.stub().callsFake(callback => callback()),
+ cancel: sinon.spy(),
+ };
+ },
+ };
+
+ globals.set("DownloadsCommon", {
+ getData: sinon.stub().returns({
+ addView: sinon.stub(),
+ removeView: sinon.stub(),
+ }),
+ copyDownloadLink: sinon.stub(),
+ deleteDownload: sinon.stub().returns(Promise.resolve()),
+ openDownload: sinon.stub(),
+ showDownloadedFile: sinon.stub(),
+ });
+
+ downloadsManager = new DownloadsManager();
+ downloadsManager.init({ dispatch() {} });
+ downloadsManager.onDownloadAdded({
+ source: { url: DOWNLOAD_URL },
+ endTime: Date.now(),
+ target: { path: "/path/to/download.mov", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ });
+ assert.ok(downloadsManager._downloadItems.has(DOWNLOAD_URL));
+
+ globals.set("NewTabUtils", { blockedLinks: { isBlocked() {} } });
+ });
+ afterEach(() => {
+ downloadsManager._downloadItems.clear();
+ globals.restore();
+ });
+ describe("#init", () => {
+ it("should add a DownloadsCommon view on init", () => {
+ downloadsManager.init({ dispatch() {} });
+ assert.calledTwice(global.DownloadsCommon.getData().addView);
+ });
+ });
+ describe("#onAction", () => {
+ it("should copy the file on COPY_DOWNLOAD_LINK", () => {
+ downloadsManager.onAction({
+ type: at.COPY_DOWNLOAD_LINK,
+ data: { url: DOWNLOAD_URL },
+ });
+ assert.calledOnce(global.DownloadsCommon.copyDownloadLink);
+ });
+ it("should remove the file on REMOVE_DOWNLOAD_FILE", () => {
+ downloadsManager.onAction({
+ type: at.REMOVE_DOWNLOAD_FILE,
+ data: { url: DOWNLOAD_URL },
+ });
+ assert.calledOnce(global.DownloadsCommon.deleteDownload);
+ });
+ it("should show the file on SHOW_DOWNLOAD_FILE", () => {
+ downloadsManager.onAction({
+ type: at.SHOW_DOWNLOAD_FILE,
+ data: { url: DOWNLOAD_URL },
+ });
+ assert.calledOnce(global.DownloadsCommon.showDownloadedFile);
+ });
+ it("should open the file on OPEN_DOWNLOAD_FILE if the type is download", () => {
+ downloadsManager.onAction({
+ type: at.OPEN_DOWNLOAD_FILE,
+ data: { url: DOWNLOAD_URL, type: "download" },
+ _target: { browser: {} },
+ });
+ assert.calledOnce(global.DownloadsCommon.openDownload);
+ });
+ it("should copy the file on UNINIT", () => {
+ // DownloadsManager._downloadData needs to exist first
+ downloadsManager.onAction({ type: at.UNINIT });
+ assert.calledOnce(global.DownloadsCommon.getData().removeView);
+ });
+ it("should not execute a download command if we do not have the correct url", () => {
+ downloadsManager.onAction({
+ type: at.SHOW_DOWNLOAD_FILE,
+ data: { url: "unknown_url" },
+ });
+ assert.notCalled(global.DownloadsCommon.showDownloadedFile);
+ });
+ });
+ describe("#onDownloadAdded", () => {
+ let newDownload;
+ beforeEach(() => {
+ downloadsManager._downloadItems.clear();
+ newDownload = {
+ source: { url: "https://site.com/newDownload.mov" },
+ endTime: Date.now(),
+ target: { path: "/path/to/newDownload.mov", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ });
+ afterEach(() => {
+ downloadsManager._downloadItems.clear();
+ });
+ it("should add a download on onDownloadAdded", () => {
+ downloadsManager.onDownloadAdded(newDownload);
+ assert.ok(
+ downloadsManager._downloadItems.has("https://site.com/newDownload.mov")
+ );
+ });
+ it("should not add a download if it already exists", () => {
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = downloadsManager._downloadItems;
+ assert.equal(results.size, 1);
+ });
+ it("should not return any downloads if no threshold is provided", async () => {
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = await downloadsManager.getDownloads(null, {});
+ assert.equal(results.length, 0);
+ });
+ it("should stop at numItems when it found one it's looking for", async () => {
+ const aDownload = {
+ source: { url: "https://site.com/aDownload.pdf" },
+ endTime: Date.now(),
+ target: { path: "/path/to/aDownload.pdf", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(aDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 1,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].url, aDownload.source.url);
+ });
+ it("should get all the downloads younger than the threshold provided", async () => {
+ const oldDownload = {
+ source: { url: "https://site.com/oldDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/oldDownload.pdf", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ // Add an old download (older than 36 hours in this case)
+ downloadsManager.onDownloadAdded(oldDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ const RECENT_DOWNLOAD_THRESHOLD = 36 * 60 * 60 * 1000;
+ const results = await downloadsManager.getDownloads(
+ RECENT_DOWNLOAD_THRESHOLD,
+ { numItems: 5, onlySucceeded: true, onlyExists: true }
+ );
+ assert.equal(results.length, 1);
+ assert.equal(results[0].url, newDownload.source.url);
+ });
+ it("should dispatch DOWNLOAD_CHANGED when adding a download", () => {
+ downloadsManager._store.dispatch = sinon.spy();
+ downloadsManager._downloadTimer = null; // Nuke the timer
+ downloadsManager.onDownloadAdded(newDownload);
+ assert.calledOnce(downloadsManager._store.dispatch);
+ });
+ it("should refresh the downloads if onlyExists is true", async () => {
+ const aDownload = {
+ source: { url: "https://site.com/aDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/aDownload.pdf", exists: true },
+ succeeded: true,
+ refresh: () => {},
+ };
+ sinon.stub(aDownload, "refresh").returns(Promise.resolve());
+ downloadsManager.onDownloadAdded(aDownload);
+ await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.calledOnce(aDownload.refresh);
+ });
+ it("should not refresh the downloads if onlyExists is false (by default)", async () => {
+ const aDownload = {
+ source: { url: "https://site.com/aDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/aDownload.pdf", exists: true },
+ succeeded: true,
+ refresh: () => {},
+ };
+ sinon.stub(aDownload, "refresh").returns(Promise.resolve());
+ downloadsManager.onDownloadAdded(aDownload);
+ await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ });
+ assert.notCalled(aDownload.refresh);
+ });
+ it("should only return downloads that exist if specified", async () => {
+ const nonExistantDownload = {
+ source: { url: "https://site.com/nonExistantDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonExistantDownload.pdf", exists: false },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonExistantDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].url, newDownload.source.url);
+ });
+ it("should return all downloads that either exist or don't exist if not specified", async () => {
+ const nonExistantDownload = {
+ source: { url: "https://site.com/nonExistantDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonExistantDownload.pdf", exists: false },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonExistantDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ });
+ assert.equal(results.length, 2);
+ assert.equal(results[0].url, newDownload.source.url);
+ assert.equal(results[1].url, nonExistantDownload.source.url);
+ });
+ it("should return only unblocked downloads", async () => {
+ const nonExistantDownload = {
+ source: { url: "https://site.com/nonExistantDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonExistantDownload.pdf", exists: false },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonExistantDownload);
+ globals.set("NewTabUtils", {
+ blockedLinks: {
+ isBlocked: item => item.url === nonExistantDownload.source.url,
+ },
+ });
+
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ });
+
+ assert.equal(results.length, 1);
+ assert.propertyVal(results[0], "url", newDownload.source.url);
+ });
+ it("should only return downloads that were successful if specified", async () => {
+ const nonSuccessfulDownload = {
+ source: { url: "https://site.com/nonSuccessfulDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonSuccessfulDownload.pdf", exists: false },
+ succeeded: false,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonSuccessfulDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].url, newDownload.source.url);
+ });
+ it("should return all downloads that were either successful or not if not specified", async () => {
+ const nonExistantDownload = {
+ source: { url: "https://site.com/nonExistantDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonExistantDownload.pdf", exists: true },
+ succeeded: false,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonExistantDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ });
+ assert.equal(results.length, 2);
+ assert.equal(results[0].url, newDownload.source.url);
+ assert.equal(results[1].url, nonExistantDownload.source.url);
+ });
+ it("should sort the downloads by recency", async () => {
+ const olderDownload1 = {
+ source: { url: "https://site.com/oldDownload1.pdf" },
+ endTime: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
+ target: { path: "/path/to/oldDownload1.pdf", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ const olderDownload2 = {
+ source: { url: "https://site.com/oldDownload2.pdf" },
+ endTime: Date.now() - 60 * 60 * 1000, // 1 hour ago
+ target: { path: "/path/to/oldDownload2.pdf", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ // Add some older downloads and check that they are in order
+ downloadsManager.onDownloadAdded(olderDownload1);
+ downloadsManager.onDownloadAdded(olderDownload2);
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.equal(results.length, 3);
+ assert.equal(results[0].url, newDownload.source.url);
+ assert.equal(results[1].url, olderDownload2.source.url);
+ assert.equal(results[2].url, olderDownload1.source.url);
+ });
+ it("should format the description properly if there is no file type", async () => {
+ newDownload.target.path = null;
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].description, "1.5 MB"); // see unit-entry.js to see where this comes from
+ });
+ });
+ describe("#onDownloadRemoved", () => {
+ let newDownload;
+ beforeEach(() => {
+ downloadsManager._downloadItems.clear();
+ newDownload = {
+ source: { url: "https://site.com/removeMe.mov" },
+ endTime: Date.now(),
+ target: { path: "/path/to/removeMe.mov", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ });
+ it("should remove a download if it exists on onDownloadRemoved", async () => {
+ downloadsManager.onDownloadRemoved({
+ source: { url: "https://site.com/removeMe.mov" },
+ });
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ });
+ assert.deepEqual(results, []);
+ });
+ it("should dispatch DOWNLOAD_CHANGED when removing a download", () => {
+ downloadsManager._store.dispatch = sinon.spy();
+ downloadsManager.onDownloadRemoved({
+ source: { url: "https://site.com/removeMe.mov" },
+ });
+ assert.calledOnce(downloadsManager._store.dispatch);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/FaviconFeed.test.js b/browser/components/newtab/test/unit/lib/FaviconFeed.test.js
new file mode 100644
index 0000000000..6476e2a3be
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/FaviconFeed.test.js
@@ -0,0 +1,233 @@
+"use strict";
+import { FaviconFeed, fetchIconFromRedirects } from "lib/FaviconFeed.jsm";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+const FAKE_ENDPOINT = "https://foo.com/";
+
+describe("FaviconFeed", () => {
+ let feed;
+ let globals;
+ let sandbox;
+ let clock;
+ let siteIconsPref;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers();
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ globals.set("PlacesUtils", {
+ favicons: {
+ setAndFetchFaviconForPage: sandbox.spy(),
+ getFaviconDataForPage: () => Promise.resolve(null),
+ FAVICON_LOAD_NON_PRIVATE: 1,
+ },
+ history: {
+ TRANSITIONS: {
+ REDIRECT_TEMPORARY: 1,
+ REDIRECT_PERMANENT: 2,
+ },
+ },
+ });
+ globals.set("NewTabUtils", {
+ activityStreamProvider: { executePlacesQuery: () => Promise.resolve([]) },
+ });
+ siteIconsPref = true;
+ sandbox
+ .stub(global.Services.prefs, "getBoolPref")
+ .withArgs("browser.chrome.site_icons")
+ .callsFake(() => siteIconsPref);
+
+ feed = new FaviconFeed();
+ feed.store = {
+ dispatch: sinon.spy(),
+ getState() {
+ return this.state;
+ },
+ state: {
+ Prefs: { values: { "tippyTop.service.endpoint": FAKE_ENDPOINT } },
+ },
+ };
+ });
+ afterEach(() => {
+ clock.restore();
+ globals.restore();
+ });
+
+ it("should create a FaviconFeed", () => {
+ assert.instanceOf(feed, FaviconFeed);
+ });
+
+ describe("#fetchIcon", () => {
+ let domain;
+ let url;
+ beforeEach(() => {
+ domain = "mozilla.org";
+ url = `https://${domain}/`;
+ feed.getSite = sandbox
+ .stub()
+ .returns(Promise.resolve({ domain, image_url: `${url}/icon.png` }));
+ feed._queryForRedirects.clear();
+ });
+
+ it("should setAndFetchFaviconForPage if the url is in the TippyTop data", async () => {
+ await feed.fetchIcon(url);
+
+ assert.calledOnce(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ assert.calledWith(
+ global.PlacesUtils.favicons.setAndFetchFaviconForPage,
+ sinon.match({ spec: url }),
+ { ref: "tippytop", spec: `${url}/icon.png` },
+ false,
+ global.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ undefined
+ );
+ });
+ it("should NOT setAndFetchFaviconForPage if site_icons pref is false", async () => {
+ siteIconsPref = false;
+
+ await feed.fetchIcon(url);
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ it("should NOT setAndFetchFaviconForPage if the url is NOT in the TippyTop data", async () => {
+ feed.getSite = sandbox.stub().returns(Promise.resolve(null));
+ await feed.fetchIcon("https://example.com");
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ it("should issue a fetchIconFromRedirects if the url is NOT in the TippyTop data", async () => {
+ feed.getSite = sandbox.stub().returns(Promise.resolve(null));
+ sandbox.spy(global.Services.tm, "idleDispatchToMainThread");
+
+ await feed.fetchIcon("https://example.com");
+
+ assert.calledOnce(global.Services.tm.idleDispatchToMainThread);
+ });
+ it("should only issue fetchIconFromRedirects once on the same url", async () => {
+ feed.getSite = sandbox.stub().returns(Promise.resolve(null));
+ sandbox.spy(global.Services.tm, "idleDispatchToMainThread");
+
+ await feed.fetchIcon("https://example.com");
+ await feed.fetchIcon("https://example.com");
+
+ assert.calledOnce(global.Services.tm.idleDispatchToMainThread);
+ });
+ it("should issue fetchIconFromRedirects twice on two different urls", async () => {
+ feed.getSite = sandbox.stub().returns(Promise.resolve(null));
+ sandbox.spy(global.Services.tm, "idleDispatchToMainThread");
+
+ await feed.fetchIcon("https://example.com");
+ await feed.fetchIcon("https://another.example.com");
+
+ assert.calledTwice(global.Services.tm.idleDispatchToMainThread);
+ });
+ });
+
+ describe("#getSite", () => {
+ it("should return site data if RemoteSettings has an entry for the domain", async () => {
+ const get = () =>
+ Promise.resolve([{ domain: "example.com", image_url: "foo.img" }]);
+ feed._tippyTop = { get };
+ const site = await feed.getSite("example.com");
+ assert.equal(site.domain, "example.com");
+ });
+ it("should return null if RemoteSettings doesn't have an entry for the domain", async () => {
+ const get = () => Promise.resolve([]);
+ feed._tippyTop = { get };
+ const site = await feed.getSite("example.com");
+ assert.isNull(site);
+ });
+ it("should lazy init _tippyTop", async () => {
+ assert.isUndefined(feed._tippyTop);
+ await feed.getSite("example.com");
+ assert.ok(feed._tippyTop);
+ });
+ });
+
+ describe("#onAction", () => {
+ it("should fetchIcon on RICH_ICON_MISSING", async () => {
+ feed.fetchIcon = sinon.spy();
+ const url = "https://mozilla.org";
+ feed.onAction({ type: at.RICH_ICON_MISSING, data: { url } });
+ assert.calledOnce(feed.fetchIcon);
+ assert.calledWith(feed.fetchIcon, url);
+ });
+ });
+
+ describe("#fetchIconFromRedirects", () => {
+ let domain;
+ let url;
+ let iconUrl;
+
+ beforeEach(() => {
+ domain = "mozilla.org";
+ url = `https://${domain}/`;
+ iconUrl = `${url}/icon.png`;
+ });
+ it("should setAndFetchFaviconForPage if the url was redirected with a icon", async () => {
+ sandbox
+ .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery")
+ .resolves([
+ { visit_id: 1, url: domain },
+ { visit_id: 2, url },
+ ]);
+ sandbox
+ .stub(global.PlacesUtils.favicons, "getFaviconDataForPage")
+ .callsArgWith(1, { spec: iconUrl }, 0, null, null, 96);
+
+ await fetchIconFromRedirects(domain);
+
+ assert.calledOnce(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ assert.calledWith(
+ global.PlacesUtils.favicons.setAndFetchFaviconForPage,
+ sinon.match({ spec: domain }),
+ { spec: iconUrl },
+ false,
+ global.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ undefined
+ );
+ });
+ it("should NOT setAndFetchFaviconForPage if the url doesn't have any redirect", async () => {
+ sandbox
+ .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery")
+ .resolves([]);
+
+ await fetchIconFromRedirects(domain);
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ it("should NOT setAndFetchFaviconForPage if the original url doesn't have a icon", async () => {
+ sandbox
+ .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery")
+ .resolves([
+ { visit_id: 1, url: domain },
+ { visit_id: 2, url },
+ ]);
+ sandbox
+ .stub(global.PlacesUtils.favicons, "getFaviconDataForPage")
+ .callsArgWith(1, null, null, null, null, null);
+
+ await fetchIconFromRedirects(domain);
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ it("should NOT setAndFetchFaviconForPage if the original url doesn't have a rich icon", async () => {
+ sandbox
+ .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery")
+ .resolves([
+ { visit_id: 1, url: domain },
+ { visit_id: 2, url },
+ ]);
+ sandbox
+ .stub(global.PlacesUtils.favicons, "getFaviconDataForPage")
+ .callsArgWith(1, { spec: iconUrl }, 0, null, null, 16);
+
+ await fetchIconFromRedirects(domain);
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/FilterAdult.test.js b/browser/components/newtab/test/unit/lib/FilterAdult.test.js
new file mode 100644
index 0000000000..e5d15a3fb0
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/FilterAdult.test.js
@@ -0,0 +1,112 @@
+import { FilterAdult } from "lib/FilterAdult.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("FilterAdult", () => {
+ let hashStub;
+ let hashValue;
+ let globals;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ hashStub = {
+ finish: sinon.stub().callsFake(() => hashValue),
+ init: sinon.stub(),
+ update: sinon.stub(),
+ };
+ globals.set("Cc", {
+ "@mozilla.org/security/hash;1": {
+ createInstance() {
+ return hashStub;
+ },
+ },
+ });
+ globals.set("gFilterAdultEnabled", true);
+ });
+
+ afterEach(() => {
+ hashValue = "";
+ globals.restore();
+ });
+
+ describe("filter", () => {
+ it("should default to include on unexpected urls", () => {
+ const empty = {};
+
+ const result = FilterAdult.filter([empty]);
+
+ assert.equal(result.length, 1);
+ assert.equal(result[0], empty);
+ });
+ it("should not filter out non-adult urls", () => {
+ const link = { url: "https://mozilla.org/" };
+
+ const result = FilterAdult.filter([link]);
+
+ assert.equal(result.length, 1);
+ assert.equal(result[0], link);
+ });
+ it("should filter out adult urls", () => {
+ // Use a hash value that is in the adult set
+ hashValue = "+/UCpAhZhz368iGioEO8aQ==";
+ const link = { url: "https://some-adult-site/" };
+
+ const result = FilterAdult.filter([link]);
+
+ assert.equal(result.length, 0);
+ });
+ it("should not filter out adult urls if the preference is turned off", () => {
+ // Use a hash value that is in the adult set
+ hashValue = "+/UCpAhZhz368iGioEO8aQ==";
+ globals.set("gFilterAdultEnabled", false);
+ const link = { url: "https://some-adult-site/" };
+
+ const result = FilterAdult.filter([link]);
+
+ assert.equal(result.length, 1);
+ assert.equal(result[0], link);
+ });
+ });
+
+ describe("isAdultUrl", () => {
+ it("should default to false on unexpected urls", () => {
+ const result = FilterAdult.isAdultUrl("");
+
+ assert.equal(result, false);
+ });
+ it("should return false for non-adult urls", () => {
+ const result = FilterAdult.isAdultUrl("https://mozilla.org/");
+
+ assert.equal(result, false);
+ });
+ it("should return true for adult urls", () => {
+ // Use a hash value that is in the adult set
+ hashValue = "+/UCpAhZhz368iGioEO8aQ==";
+ const result = FilterAdult.isAdultUrl("https://some-adult-site/");
+
+ assert.equal(result, true);
+ });
+ it("should return false for adult urls when the preference is turned off", () => {
+ // Use a hash value that is in the adult set
+ hashValue = "+/UCpAhZhz368iGioEO8aQ==";
+ globals.set("gFilterAdultEnabled", false);
+ const result = FilterAdult.isAdultUrl("https://some-adult-site/");
+
+ assert.equal(result, false);
+ });
+
+ describe("test functions", () => {
+ it("should add and remove a filter in the adult list", () => {
+ // Use a hash value that is in the adult set
+ FilterAdult.addDomainToList("https://some-adult-site/");
+ let result = FilterAdult.isAdultUrl("https://some-adult-site/");
+
+ assert.equal(result, true);
+
+ FilterAdult.removeDomainFromList("https://some-adult-site/");
+ result = FilterAdult.isAdultUrl("https://some-adult-site/");
+
+ assert.equal(result, false);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/HighlightsFeed.test.js b/browser/components/newtab/test/unit/lib/HighlightsFeed.test.js
new file mode 100644
index 0000000000..f0cd2450b7
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/HighlightsFeed.test.js
@@ -0,0 +1,822 @@
+"use strict";
+
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { Dedupe } from "common/Dedupe.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import injector from "inject!lib/HighlightsFeed.jsm";
+import { Screenshots } from "lib/Screenshots.jsm";
+import { LinksCache } from "lib/LinksCache.sys.mjs";
+
+const FAKE_LINKS = new Array(20)
+ .fill(null)
+ .map((v, i) => ({ url: `http://www.site${i}.com` }));
+const FAKE_IMAGE = "data123";
+
+describe("Highlights Feed", () => {
+ let HighlightsFeed;
+ let SECTION_ID;
+ let SYNC_BOOKMARKS_FINISHED_EVENT;
+ let BOOKMARKS_RESTORE_SUCCESS_EVENT;
+ let BOOKMARKS_RESTORE_FAILED_EVENT;
+ let feed;
+ let globals;
+ let sandbox;
+ let links;
+ let fakeScreenshot;
+ let fakeNewTabUtils;
+ let filterAdultStub;
+ let sectionsManagerStub;
+ let downloadsManagerStub;
+ let shortURLStub;
+ let fakePageThumbs;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ fakeNewTabUtils = {
+ activityStreamLinks: {
+ getHighlights: sandbox.spy(() => Promise.resolve(links)),
+ deletePocketEntry: sandbox.spy(() => Promise.resolve({})),
+ archivePocketEntry: sandbox.spy(() => Promise.resolve({})),
+ },
+ activityStreamProvider: {
+ _processHighlights: sandbox.spy(l => l.slice(0, 1)),
+ },
+ };
+ sectionsManagerStub = {
+ onceInitialized: sinon.stub().callsFake(callback => callback()),
+ enableSection: sinon.spy(),
+ disableSection: sinon.spy(),
+ updateSection: sinon.spy(),
+ updateSectionCard: sinon.spy(),
+ sections: new Map([["highlights", { id: "highlights" }]]),
+ };
+ downloadsManagerStub = sinon.stub().returns({
+ getDownloads: () => [{ url: "https://site.com/download" }],
+ onAction: sinon.spy(),
+ init: sinon.spy(),
+ });
+ fakeScreenshot = {
+ getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_IMAGE)),
+ maybeCacheScreenshot: Screenshots.maybeCacheScreenshot,
+ _shouldGetScreenshots: sinon.stub().returns(true),
+ };
+ filterAdultStub = {
+ filter: sinon.stub().returnsArg(0),
+ };
+ shortURLStub = sinon
+ .stub()
+ .callsFake(site => site.url.match(/\/([^/]+)/)[1]);
+ fakePageThumbs = {
+ addExpirationFilter: sinon.stub(),
+ removeExpirationFilter: sinon.stub(),
+ };
+
+ globals.set({
+ NewTabUtils: fakeNewTabUtils,
+ PageThumbs: fakePageThumbs,
+ gFilterAdultEnabled: false,
+ LinksCache,
+ DownloadsManager: downloadsManagerStub,
+ FilterAdult: filterAdultStub,
+ Screenshots: fakeScreenshot,
+ });
+ ({
+ HighlightsFeed,
+ SECTION_ID,
+ SYNC_BOOKMARKS_FINISHED_EVENT,
+ BOOKMARKS_RESTORE_SUCCESS_EVENT,
+ BOOKMARKS_RESTORE_FAILED_EVENT,
+ } = injector({
+ "lib/FilterAdult.jsm": { FilterAdult: filterAdultStub },
+ "lib/ShortURL.jsm": { shortURL: shortURLStub },
+ "lib/SectionsManager.jsm": { SectionsManager: sectionsManagerStub },
+ "lib/Screenshots.jsm": { Screenshots: fakeScreenshot },
+ "common/Dedupe.jsm": { Dedupe },
+ "lib/DownloadsManager.jsm": { DownloadsManager: downloadsManagerStub },
+ }));
+ sandbox.spy(global.Services.obs, "addObserver");
+ sandbox.spy(global.Services.obs, "removeObserver");
+ feed = new HighlightsFeed();
+ feed.store = {
+ dispatch: sinon.spy(),
+ getState() {
+ return this.state;
+ },
+ state: {
+ Prefs: {
+ values: {
+ "section.highlights.includePocket": false,
+ "section.highlights.includeDownloads": false,
+ },
+ },
+ TopSites: {
+ initialized: true,
+ rows: Array(12)
+ .fill(null)
+ .map((v, i) => ({ url: `http://www.topsite${i}.com` })),
+ },
+ Sections: [{ id: "highlights", initialized: false }],
+ },
+ subscribe: sinon.stub().callsFake(cb => {
+ cb();
+ return () => {};
+ }),
+ };
+ links = FAKE_LINKS;
+ });
+ afterEach(() => {
+ globals.restore();
+ });
+
+ describe("#init", () => {
+ it("should create a HighlightsFeed", () => {
+ assert.instanceOf(feed, HighlightsFeed);
+ });
+ it("should register a expiration filter", () => {
+ assert.calledOnce(fakePageThumbs.addExpirationFilter);
+ });
+ it("should add the sync observer", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledWith(
+ global.Services.obs.addObserver,
+ feed,
+ SYNC_BOOKMARKS_FINISHED_EVENT
+ );
+ assert.calledWith(
+ global.Services.obs.addObserver,
+ feed,
+ BOOKMARKS_RESTORE_SUCCESS_EVENT
+ );
+ assert.calledWith(
+ global.Services.obs.addObserver,
+ feed,
+ BOOKMARKS_RESTORE_FAILED_EVENT
+ );
+ });
+ it("should call SectionsManager.onceInitialized on INIT", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(sectionsManagerStub.onceInitialized);
+ });
+ it("should enable its section", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(sectionsManagerStub.enableSection);
+ assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID);
+ });
+ it("should fetch highlights on postInit", () => {
+ feed.fetchHighlights = sinon.spy();
+ feed.postInit();
+ assert.calledOnce(feed.fetchHighlights);
+ });
+ it("should hook up the store for the DownloadsManager", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(feed.downloadsManager.init);
+ });
+ });
+ describe("#observe", () => {
+ beforeEach(() => {
+ feed.fetchHighlights = sinon.spy();
+ });
+ it("should fetch higlights when we are done a sync for bookmarks", () => {
+ feed.observe(null, SYNC_BOOKMARKS_FINISHED_EVENT, "bookmarks");
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights after a successful import", () => {
+ feed.observe(null, BOOKMARKS_RESTORE_SUCCESS_EVENT, "html");
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights after a failed import", () => {
+ feed.observe(null, BOOKMARKS_RESTORE_FAILED_EVENT, "json");
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should not fetch higlights when we are doing a sync for something that is not bookmarks", () => {
+ feed.observe(null, SYNC_BOOKMARKS_FINISHED_EVENT, "tabs");
+ assert.notCalled(feed.fetchHighlights);
+ });
+ it("should not fetch higlights for other events", () => {
+ feed.observe(null, "someotherevent", "bookmarks");
+ assert.notCalled(feed.fetchHighlights);
+ });
+ });
+ describe("#filterForThumbnailExpiration", () => {
+ it("should pass rows.urls to the callback provided", () => {
+ const rows = [{ url: "foo.com" }, { url: "bar.com" }];
+ feed.store.state.Sections = [
+ { id: "highlights", rows, initialized: true },
+ ];
+ const stub = sinon.stub();
+
+ feed.filterForThumbnailExpiration(stub);
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(
+ stub,
+ rows.map(r => r.url)
+ );
+ });
+ it("should include preview_image_url (if present) in the callback results", () => {
+ const rows = [
+ { url: "foo.com" },
+ { url: "bar.com", preview_image_url: "bar.jpg" },
+ ];
+ feed.store.state.Sections = [
+ { id: "highlights", rows, initialized: true },
+ ];
+ const stub = sinon.stub();
+
+ feed.filterForThumbnailExpiration(stub);
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, ["foo.com", "bar.com", "bar.jpg"]);
+ });
+ it("should pass an empty array if not initialized", () => {
+ const rows = [{ url: "foo.com" }, { url: "bar.com" }];
+ feed.store.state.Sections = [{ rows, initialized: false }];
+ const stub = sinon.stub();
+
+ feed.filterForThumbnailExpiration(stub);
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, []);
+ });
+ });
+ describe("#fetchHighlights", () => {
+ const fetchHighlights = async options => {
+ await feed.fetchHighlights(options);
+ return sectionsManagerStub.updateSection.firstCall.args[1].rows;
+ };
+ it("should return early if TopSites are not initialised", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ feed.store.state.TopSites.initialized = false;
+ feed.store.state.Prefs.values["feeds.topsites"] = true;
+ feed.store.state.Prefs.values["feeds.system.topsites"] = true;
+
+ // Initially TopSites is uninitialised and fetchHighlights should return.
+ await feed.fetchHighlights();
+
+ assert.notCalled(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ assert.notCalled(feed.linksCache.request);
+ });
+ it("should return early if Sections are not initialised", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ feed.store.state.TopSites.initialized = true;
+ feed.store.state.Prefs.values["feeds.topsites"] = true;
+ feed.store.state.Prefs.values["feeds.system.topsites"] = true;
+ feed.store.state.Sections = [];
+
+ await feed.fetchHighlights();
+
+ assert.notCalled(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ assert.notCalled(feed.linksCache.request);
+ });
+ it("should fetch Highlights if TopSites are initialised", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ // fetchHighlights should continue
+ feed.store.state.TopSites.initialized = true;
+
+ await feed.fetchHighlights();
+
+ assert.calledOnce(feed.linksCache.request);
+ assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ });
+ it("should chronologically order highlight data types", async () => {
+ links = [
+ {
+ url: "https://site0.com",
+ type: "bookmark",
+ bookmarkGuid: "1234",
+ date_added: Date.now() - 80,
+ }, // 3rd newest
+ {
+ url: "https://site1.com",
+ type: "history",
+ bookmarkGuid: "1234",
+ date_added: Date.now() - 60,
+ }, // append at the end
+ {
+ url: "https://site2.com",
+ type: "history",
+ date_added: Date.now() - 160,
+ }, // append at the end
+ {
+ url: "https://site3.com",
+ type: "history",
+ date_added: Date.now() - 60,
+ }, // append at the end
+ { url: "https://site4.com", type: "pocket", date_added: Date.now() }, // newest highlight
+ {
+ url: "https://site5.com",
+ type: "pocket",
+ date_added: Date.now() - 100,
+ }, // 4th newest
+ {
+ url: "https://site6.com",
+ type: "bookmark",
+ bookmarkGuid: "1234",
+ date_added: Date.now() - 40,
+ }, // 2nd newest
+ ];
+ const expectedChronological = [4, 6, 0, 5];
+ const expectedHistory = [1, 2, 3];
+
+ let highlights = await fetchHighlights();
+
+ [...expectedChronological, ...expectedHistory].forEach((link, index) => {
+ assert.propertyVal(
+ highlights[index],
+ "url",
+ links[link].url,
+ `highlight[${index}] should be link[${link}]`
+ );
+ });
+ });
+ it("should fetch Highlights if TopSites are not enabled", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ feed.store.state.Prefs.values["feeds.system.topsites"] = false;
+
+ await feed.fetchHighlights();
+
+ assert.calledOnce(feed.linksCache.request);
+ assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ });
+ it("should fetch Highlights if TopSites are not shown on NTP", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ feed.store.state.Prefs.values["feeds.topsites"] = false;
+
+ await feed.fetchHighlights();
+
+ assert.calledOnce(feed.linksCache.request);
+ assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ });
+ it("should add hostname and hasImage to each link", async () => {
+ links = [{ url: "https://mozilla.org" }];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights[0].hostname, "mozilla.org");
+ assert.equal(highlights[0].hasImage, true);
+ });
+ it("should add an existing image if it exists to the link without calling fetchImage", async () => {
+ links = [{ url: "https://mozilla.org", image: FAKE_IMAGE }];
+ sinon.spy(feed, "fetchImage");
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights[0].image, FAKE_IMAGE);
+ assert.notCalled(feed.fetchImage);
+ });
+ it("should call fetchImage with the correct arguments for new links", async () => {
+ links = [
+ {
+ url: "https://mozilla.org",
+ preview_image_url: "https://mozilla.org/preview.jog",
+ },
+ ];
+ sinon.spy(feed, "fetchImage");
+
+ await feed.fetchHighlights();
+
+ assert.calledOnce(feed.fetchImage);
+ const [arg] = feed.fetchImage.firstCall.args;
+ assert.propertyVal(arg, "url", links[0].url);
+ assert.propertyVal(arg, "preview_image_url", links[0].preview_image_url);
+ });
+ it("should not include any links already in Top Sites", async () => {
+ links = [
+ { url: "https://mozilla.org" },
+ { url: "http://www.topsite0.com" },
+ { url: "http://www.topsite1.com" },
+ { url: "http://www.topsite2.com" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights.length, 1);
+ assert.equal(highlights[0].url, links[0].url);
+ });
+ it("should include bookmark but not history already in Top Sites", async () => {
+ links = [
+ { url: "http://www.topsite0.com", type: "bookmark" },
+ { url: "http://www.topsite1.com", type: "history" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights.length, 1);
+ assert.equal(highlights[0].url, links[0].url);
+ });
+ it("should not include history of same hostname as a bookmark", async () => {
+ links = [
+ { url: "https://site.com/bookmark", type: "bookmark" },
+ { url: "https://site.com/history", type: "history" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights.length, 1);
+ assert.equal(highlights[0].url, links[0].url);
+ });
+ it("should take the first history of a hostname", async () => {
+ links = [
+ { url: "https://site.com/first", type: "history" },
+ { url: "https://site.com/second", type: "history" },
+ { url: "https://other", type: "history" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights.length, 2);
+ assert.equal(highlights[0].url, links[0].url);
+ assert.equal(highlights[1].url, links[2].url);
+ });
+ it("should take a bookmark, a pocket, and downloaded item of the same hostname", async () => {
+ links = [
+ { url: "https://site.com/bookmark", type: "bookmark" },
+ { url: "https://site.com/pocket", type: "pocket" },
+ { url: "https://site.com/download", type: "download" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights.length, 3);
+ assert.equal(highlights[0].url, links[0].url);
+ assert.equal(highlights[1].url, links[1].url);
+ assert.equal(highlights[2].url, links[2].url);
+ });
+ it("should includePocket pocket items when pref is true", async () => {
+ feed.store.state.Prefs.values["section.highlights.includePocket"] = true;
+ sandbox.spy(feed.linksCache, "request");
+ await feed.fetchHighlights();
+
+ assert.propertyVal(
+ feed.linksCache.request.firstCall.args[0],
+ "excludePocket",
+ false
+ );
+ });
+ it("should not includePocket pocket items when pref is false", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ await feed.fetchHighlights();
+
+ assert.propertyVal(
+ feed.linksCache.request.firstCall.args[0],
+ "excludePocket",
+ true
+ );
+ });
+ it("should not include downloads when includeDownloads pref is false", async () => {
+ links = [
+ { url: "https://site.com/bookmark", type: "bookmark" },
+ { url: "https://site.com/pocket", type: "pocket" },
+ ];
+
+ // Check that we don't have the downloaded item in highlights
+ const highlights = await fetchHighlights();
+ assert.equal(highlights.length, 2);
+ assert.equal(highlights[0].url, links[0].url);
+ assert.equal(highlights[1].url, links[1].url);
+ });
+ it("should include downloads when includeDownloads pref is true", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeDownloads"
+ ] = true;
+ links = [
+ { url: "https://site.com/bookmark", type: "bookmark" },
+ { url: "https://site.com/pocket", type: "pocket" },
+ ];
+
+ // Check that we did get the downloaded item in highlights
+ const highlights = await fetchHighlights();
+ assert.equal(highlights.length, 3);
+ assert.equal(highlights[0].url, links[0].url);
+ assert.equal(highlights[1].url, links[1].url);
+ assert.equal(highlights[2].url, "https://site.com/download");
+
+ assert.propertyVal(highlights[2], "type", "download");
+ });
+ it("should only take 1 download", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeDownloads"
+ ] = true;
+ feed.downloadsManager.getDownloads = () => [
+ { url: "https://site1.com/download" },
+ { url: "https://site2.com/download" },
+ ];
+ links = [{ url: "https://site.com/bookmark", type: "bookmark" }];
+
+ // Check that we did get the most single recent downloaded item in highlights
+ const highlights = await fetchHighlights();
+ assert.equal(highlights.length, 2);
+ assert.equal(highlights[0].url, links[0].url);
+ assert.equal(highlights[1].url, "https://site1.com/download");
+ });
+ it("should sort bookmarks, pocket, and downloads chronologically", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeDownloads"
+ ] = true;
+ feed.downloadsManager.getDownloads = () => [
+ {
+ url: "https://site1.com/download",
+ type: "download",
+ date_added: Date.now(),
+ },
+ ];
+ links = [
+ {
+ url: "https://site.com/bookmark",
+ type: "bookmark",
+ date_added: Date.now() - 10000,
+ },
+ {
+ url: "https://site2.com/pocket",
+ type: "pocket",
+ date_added: Date.now() - 5000,
+ },
+ {
+ url: "https://site3.com/visited",
+ type: "history",
+ date_added: Date.now(),
+ },
+ ];
+
+ // Check that the higlights are ordered chronologically by their 'date_added'
+ const highlights = await fetchHighlights();
+ assert.equal(highlights.length, 4);
+ assert.equal(highlights[0].url, "https://site1.com/download");
+ assert.equal(highlights[1].url, links[1].url);
+ assert.equal(highlights[2].url, links[0].url);
+ assert.equal(highlights[3].url, links[2].url); // history item goes last
+ });
+ it("should set type to bookmark if there is a bookmarkGuid", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeBookmarks"
+ ] = true;
+ links = [
+ {
+ url: "https://mozilla.org",
+ type: "history",
+ bookmarkGuid: "1234567890",
+ },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights[0].type, "bookmark");
+ });
+ it("should keep history type if there is a bookmarkGuid but don't include bookmarks", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeBookmarks"
+ ] = false;
+ links = [
+ {
+ url: "https://mozilla.org",
+ type: "history",
+ bookmarkGuid: "1234567890",
+ },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.propertyVal(highlights[0], "type", "history");
+ });
+ it("should filter out adult pages", async () => {
+ filterAdultStub.filter = sinon.stub().returns([]);
+ const highlights = await fetchHighlights();
+
+ // The stub filters out everything
+ assert.calledOnce(filterAdultStub.filter);
+ assert.equal(highlights.length, 0);
+ });
+ it("should not expose internal link properties", async () => {
+ const highlights = await fetchHighlights();
+
+ const internal = Object.keys(highlights[0]).filter(key =>
+ key.startsWith("__")
+ );
+ assert.equal(internal.join(""), "");
+ });
+ it("should broadcast if feed is not initialized", async () => {
+ links = [];
+ await fetchHighlights();
+
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWithExactly(
+ sectionsManagerStub.updateSection,
+ SECTION_ID,
+ { rows: [] },
+ true,
+ undefined
+ );
+ });
+ it("should broadcast if options.broadcast is true", async () => {
+ links = [];
+ feed.store.state.Sections[0].initialized = true;
+ await fetchHighlights({ broadcast: true });
+
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWithExactly(
+ sectionsManagerStub.updateSection,
+ SECTION_ID,
+ { rows: [] },
+ true,
+ undefined
+ );
+ });
+ it("should not broadcast if options.broadcast is false and initialized is true", async () => {
+ links = [];
+ feed.store.state.Sections[0].initialized = true;
+ await fetchHighlights({ broadcast: false });
+
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWithExactly(
+ sectionsManagerStub.updateSection,
+ SECTION_ID,
+ { rows: [] },
+ false,
+ undefined
+ );
+ });
+ });
+ describe("#fetchImage", () => {
+ const FAKE_URL = "https://mozilla.org";
+ const FAKE_IMAGE_URL = "https://mozilla.org/preview.jpg";
+ function fetchImage(page) {
+ return feed.fetchImage(
+ Object.assign({ __sharedCache: { updateLink() {} } }, page)
+ );
+ }
+ it("should capture the image, if available", async () => {
+ await fetchImage({
+ preview_image_url: FAKE_IMAGE_URL,
+ url: FAKE_URL,
+ });
+
+ assert.calledOnce(fakeScreenshot.getScreenshotForURL);
+ assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_IMAGE_URL);
+ });
+ it("should fall back to capturing a screenshot", async () => {
+ await fetchImage({ url: FAKE_URL });
+
+ assert.calledOnce(fakeScreenshot.getScreenshotForURL);
+ assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_URL);
+ });
+ it("should call SectionsManager.updateSectionCard with the right arguments", async () => {
+ await fetchImage({
+ preview_image_url: FAKE_IMAGE_URL,
+ url: FAKE_URL,
+ });
+
+ assert.calledOnce(sectionsManagerStub.updateSectionCard);
+ assert.calledWith(
+ sectionsManagerStub.updateSectionCard,
+ "highlights",
+ FAKE_URL,
+ { image: FAKE_IMAGE },
+ true
+ );
+ });
+ it("should not update the card with the image", async () => {
+ const card = {
+ preview_image_url: FAKE_IMAGE_URL,
+ url: FAKE_URL,
+ };
+
+ await fetchImage(card);
+
+ assert.notProperty(card, "image");
+ });
+ });
+ describe("#uninit", () => {
+ it("should disable its section", () => {
+ feed.onAction({ type: at.UNINIT });
+ assert.calledOnce(sectionsManagerStub.disableSection);
+ assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID);
+ });
+ it("should remove the expiration filter", () => {
+ feed.onAction({ type: at.UNINIT });
+ assert.calledOnce(fakePageThumbs.removeExpirationFilter);
+ });
+ it("should remove the sync and Places observers", () => {
+ feed.onAction({ type: at.UNINIT });
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ feed,
+ SYNC_BOOKMARKS_FINISHED_EVENT
+ );
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ feed,
+ BOOKMARKS_RESTORE_SUCCESS_EVENT
+ );
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ feed,
+ BOOKMARKS_RESTORE_FAILED_EVENT
+ );
+ });
+ });
+ describe("#onAction", () => {
+ it("should relay all actions to DownloadsManager.onAction", () => {
+ let action = {
+ type: at.COPY_DOWNLOAD_LINK,
+ data: { url: "foo.png" },
+ _target: {},
+ };
+ feed.onAction(action);
+ assert.calledWith(feed.downloadsManager.onAction, action);
+ });
+ it("should fetch highlights on SYSTEM_TICK", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({ type: at.SYSTEM_TICK });
+
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWithExactly(feed.fetchHighlights, {
+ broadcast: false,
+ isStartup: false,
+ });
+ });
+ it("should fetch highlights on PREF_CHANGED for include prefs", async () => {
+ feed.fetchHighlights = sinon.spy();
+
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "section.highlights.includeBookmarks" },
+ });
+
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should not fetch highlights on PREF_CHANGED for other prefs", async () => {
+ feed.fetchHighlights = sinon.spy();
+
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "section.topstories.pocketCta" },
+ });
+
+ assert.notCalled(feed.fetchHighlights);
+ });
+ it("should fetch highlights on PLACES_HISTORY_CLEARED", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({ type: at.PLACES_HISTORY_CLEARED });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights on DOWNLOAD_CHANGED", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({ type: at.DOWNLOAD_CHANGED });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights on PLACES_LINKS_CHANGED", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ sandbox.stub(feed.linksCache, "expire");
+
+ feed.onAction({ type: at.PLACES_LINKS_CHANGED });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: false });
+ assert.calledOnce(feed.linksCache.expire);
+ });
+ it("should fetch highlights on PLACES_LINK_BLOCKED", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({ type: at.PLACES_LINK_BLOCKED });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights and expire the cache on PLACES_SAVED_TO_POCKET", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ sandbox.stub(feed.linksCache, "expire");
+
+ feed.onAction({ type: at.PLACES_SAVED_TO_POCKET });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: false });
+ assert.calledOnce(feed.linksCache.expire);
+ });
+ it("should call fetchHighlights with broadcast false on TOP_SITES_UPDATED", () => {
+ sandbox.stub(feed, "fetchHighlights");
+ feed.onAction({ type: at.TOP_SITES_UPDATED });
+
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWithExactly(feed.fetchHighlights, {
+ broadcast: false,
+ isStartup: false,
+ });
+ });
+ it("should call fetchHighlights when deleting or archiving from Pocket", async () => {
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({
+ type: at.POCKET_LINK_DELETED_OR_ARCHIVED,
+ data: { pocket_id: 12345 },
+ });
+
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWithExactly(feed.fetchHighlights, { broadcast: true });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/LinksCache.test.js b/browser/components/newtab/test/unit/lib/LinksCache.test.js
new file mode 100644
index 0000000000..8a4d33d2f2
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/LinksCache.test.js
@@ -0,0 +1,16 @@
+import { LinksCache } from "lib/LinksCache.sys.mjs";
+
+describe("LinksCache", () => {
+ it("throws when failing request", async () => {
+ const cache = new LinksCache();
+
+ let rejected = false;
+ try {
+ await cache.request();
+ } catch (error) {
+ rejected = true;
+ }
+
+ assert(rejected);
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/MomentsPageHub.test.js b/browser/components/newtab/test/unit/lib/MomentsPageHub.test.js
new file mode 100644
index 0000000000..5357290a76
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/MomentsPageHub.test.js
@@ -0,0 +1,336 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs";
+import { _MomentsPageHub } from "lib/MomentsPageHub.jsm";
+const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once";
+
+describe("MomentsPageHub", () => {
+ let globals;
+ let sandbox;
+ let instance;
+ let handleMessageRequestStub;
+ let addImpressionStub;
+ let blockMessageByIdStub;
+ let sendTelemetryStub;
+ let getStringPrefStub;
+ let setStringPrefStub;
+ let setIntervalStub;
+ let clearIntervalStub;
+
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ instance = new _MomentsPageHub();
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ ({ template }) => template === "update_action"
+ );
+ handleMessageRequestStub = sandbox.stub().resolves(messages);
+ addImpressionStub = sandbox.stub();
+ blockMessageByIdStub = sandbox.stub();
+ getStringPrefStub = sandbox.stub();
+ setStringPrefStub = sandbox.stub();
+ setIntervalStub = sandbox.stub();
+ clearIntervalStub = sandbox.stub();
+ sendTelemetryStub = sandbox.stub();
+ globals.set({
+ setInterval: setIntervalStub,
+ clearInterval: clearIntervalStub,
+ Services: {
+ prefs: {
+ getStringPref: getStringPrefStub,
+ setStringPref: setStringPrefStub,
+ },
+ telemetry: {
+ recordEvent: () => {},
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ it("should create an instance", async () => {
+ setIntervalStub.returns(42);
+ assert.ok(instance);
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+ assert.equal(instance.state._intervalId, 42);
+ });
+
+ it("should init only once", async () => {
+ assert.notCalled(handleMessageRequestStub);
+
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+
+ assert.calledOnce(handleMessageRequestStub);
+
+ instance.uninit();
+
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+
+ assert.calledTwice(handleMessageRequestStub);
+ });
+
+ it("should uninit the instance", () => {
+ instance.uninit();
+ assert.calledOnce(clearIntervalStub);
+ });
+
+ it("should setInterval for `checkHomepageOverridePref`", async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+ sandbox.stub(instance, "checkHomepageOverridePref");
+
+ assert.calledOnce(setIntervalStub);
+ assert.calledWithExactly(setIntervalStub, sinon.match.func, 5 * 60 * 1000);
+
+ assert.notCalled(instance.checkHomepageOverridePref);
+ const [cb] = setIntervalStub.firstCall.args;
+
+ cb();
+
+ assert.calledOnce(instance.checkHomepageOverridePref);
+ });
+
+ describe("#messageRequest", () => {
+ beforeEach(async () => {
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ sendTelemetry: sendTelemetryStub,
+ });
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("should fetch a message with the provided trigger and template", async () => {
+ await instance.messageRequest({
+ triggerId: "trigger",
+ template: "template",
+ });
+
+ assert.calledTwice(handleMessageRequestStub);
+ assert.calledWithExactly(handleMessageRequestStub, {
+ triggerId: "trigger",
+ template: "template",
+ returnAll: true,
+ });
+ });
+ it("shouldn't do anything if no message is provided", async () => {
+ // Reset the call from `instance.init`
+ setStringPrefStub.reset();
+ handleMessageRequestStub.resolves([]);
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.notCalled(setStringPrefStub);
+ });
+ it("should record telemetry events", async () => {
+ const startTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "start"
+ );
+ const finishTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "finish"
+ );
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(startTelemetryStopwatch);
+ assert.calledWithExactly(
+ startTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ assert.calledOnce(finishTelemetryStopwatch);
+ assert.calledWithExactly(
+ finishTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ });
+ it("should record Reach event for the Moments page experiment", async () => {
+ const momentsMessages = (await PanelTestProvider.getMessages()).filter(
+ ({ template }) => template === "update_action"
+ );
+ const messages = [
+ {
+ forReachEvent: { sent: false },
+ experimentSlug: "foo",
+ branchSlug: "bar",
+ },
+ ...momentsMessages,
+ ];
+ handleMessageRequestStub.resolves(messages);
+ sandbox.spy(global.Services.telemetry, "recordEvent");
+ sandbox.spy(instance, "executeAction");
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(global.Services.telemetry.recordEvent);
+ assert.calledOnce(instance.executeAction);
+ });
+ it("should not record the Reach event if it's already sent", async () => {
+ const messages = [
+ {
+ forReachEvent: { sent: true },
+ experimentSlug: "foo",
+ branchSlug: "bar",
+ },
+ ];
+ handleMessageRequestStub.resolves(messages);
+ sandbox.spy(global.Services.telemetry, "recordEvent");
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.notCalled(global.Services.telemetry.recordEvent);
+ });
+ it("should not trigger the action if it's only for the Reach event", async () => {
+ const messages = [
+ {
+ forReachEvent: { sent: false },
+ experimentSlug: "foo",
+ branchSlug: "bar",
+ },
+ ];
+ handleMessageRequestStub.resolves(messages);
+ sandbox.spy(global.Services.telemetry, "recordEvent");
+ sandbox.spy(instance, "executeAction");
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(global.Services.telemetry.recordEvent);
+ assert.notCalled(instance.executeAction);
+ });
+ });
+ describe("executeAction", () => {
+ beforeEach(async () => {
+ blockMessageByIdStub = sandbox.stub();
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ sendTelemetry: sendTelemetryStub,
+ });
+ });
+ it("should set HOMEPAGE_OVERRIDE_PREF on `moments-wnp` action", async () => {
+ const [msg] = await handleMessageRequestStub();
+ sandbox.useFakeTimers();
+ instance.executeAction(msg);
+
+ assert.calledOnce(setStringPrefStub);
+ assert.calledWithExactly(
+ setStringPrefStub,
+ HOMEPAGE_OVERRIDE_PREF,
+ JSON.stringify({
+ message_id: msg.id,
+ url: msg.content.action.data.url,
+ expire: instance.getExpirationDate(
+ msg.content.action.data.expireDelta
+ ),
+ })
+ );
+ });
+ it("should block after taking the action", async () => {
+ const [msg] = await handleMessageRequestStub();
+ instance.executeAction(msg);
+
+ assert.calledOnce(blockMessageByIdStub);
+ assert.calledWithExactly(blockMessageByIdStub, msg.id);
+ });
+ it("should compute expire based on expireDelta", async () => {
+ sandbox.spy(instance, "getExpirationDate");
+
+ const [msg] = await handleMessageRequestStub();
+ instance.executeAction(msg);
+
+ assert.calledOnce(instance.getExpirationDate);
+ assert.calledWithExactly(
+ instance.getExpirationDate,
+ msg.content.action.data.expireDelta
+ );
+ });
+ it("should compute expire based on expireDelta", async () => {
+ sandbox.spy(instance, "getExpirationDate");
+
+ const [msg] = await handleMessageRequestStub();
+ const msgWithExpire = {
+ ...msg,
+ content: {
+ ...msg.content,
+ action: {
+ ...msg.content.action,
+ data: { ...msg.content.action.data, expire: 41 },
+ },
+ },
+ };
+ instance.executeAction(msgWithExpire);
+
+ assert.notCalled(instance.getExpirationDate);
+ assert.calledOnce(setStringPrefStub);
+ assert.calledWithExactly(
+ setStringPrefStub,
+ HOMEPAGE_OVERRIDE_PREF,
+ JSON.stringify({
+ message_id: msg.id,
+ url: msg.content.action.data.url,
+ expire: 41,
+ })
+ );
+ });
+ it("should send user telemetry", async () => {
+ const [msg] = await handleMessageRequestStub();
+ const sendUserEventTelemetrySpy = sandbox.spy(
+ instance,
+ "sendUserEventTelemetry"
+ );
+ instance.executeAction(msg);
+
+ assert.calledOnce(sendTelemetryStub);
+ assert.calledWithExactly(sendUserEventTelemetrySpy, msg);
+ assert.calledWithExactly(sendTelemetryStub, {
+ type: "MOMENTS_PAGE_TELEMETRY",
+ data: {
+ action: "moments_user_event",
+ bucket_id: "WNP_THANK_YOU",
+ event: "MOMENTS_PAGE_SET",
+ message_id: "WNP_THANK_YOU",
+ },
+ });
+ });
+ });
+ describe("#checkHomepageOverridePref", () => {
+ let messageRequestStub;
+ beforeEach(() => {
+ messageRequestStub = sandbox.stub(instance, "messageRequest");
+ });
+ it("should catch parse errors", () => {
+ getStringPrefStub.returns({});
+
+ instance.checkHomepageOverridePref();
+
+ assert.calledOnce(messageRequestStub);
+ assert.calledWithExactly(messageRequestStub, {
+ template: "update_action",
+ triggerId: "momentsUpdate",
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/NewTabInit.test.js b/browser/components/newtab/test/unit/lib/NewTabInit.test.js
new file mode 100644
index 0000000000..834409669f
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/NewTabInit.test.js
@@ -0,0 +1,81 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { NewTabInit } from "lib/NewTabInit.jsm";
+
+describe("NewTabInit", () => {
+ let instance;
+ let store;
+ let STATE;
+ const requestFromTab = portID =>
+ instance.onAction(
+ ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST }, portID)
+ );
+ beforeEach(() => {
+ STATE = {};
+ store = { getState: sinon.stub().returns(STATE), dispatch: sinon.stub() };
+ instance = new NewTabInit();
+ instance.store = store;
+ });
+ it("should reply with a copy of the state immediately", () => {
+ requestFromTab(123);
+
+ const resp = ac.AlsoToOneContent(
+ { type: at.NEW_TAB_INITIAL_STATE, data: STATE },
+ 123
+ );
+ assert.calledWith(store.dispatch, resp);
+ });
+ describe("early / simulated new tabs", () => {
+ const simulateTabInit = portID =>
+ instance.onAction({
+ type: at.NEW_TAB_INIT,
+ data: { portID, simulated: true },
+ });
+ beforeEach(() => {
+ simulateTabInit("foo");
+ });
+ it("should dispatch if not replied yet", () => {
+ requestFromTab("foo");
+
+ assert.calledWith(
+ store.dispatch,
+ ac.AlsoToOneContent(
+ { type: at.NEW_TAB_INITIAL_STATE, data: STATE },
+ "foo"
+ )
+ );
+ });
+ it("should dispatch once for multiple requests", () => {
+ requestFromTab("foo");
+ requestFromTab("foo");
+ requestFromTab("foo");
+
+ assert.calledOnce(store.dispatch);
+ });
+ describe("multiple tabs", () => {
+ beforeEach(() => {
+ simulateTabInit("bar");
+ });
+ it("should dispatch once to each tab", () => {
+ requestFromTab("foo");
+ requestFromTab("bar");
+ assert.calledTwice(store.dispatch);
+ requestFromTab("foo");
+ requestFromTab("bar");
+
+ assert.calledTwice(store.dispatch);
+ });
+ it("should clean up when tabs close", () => {
+ assert.propertyVal(instance._repliedEarlyTabs, "size", 2);
+ instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "foo"));
+ assert.propertyVal(instance._repliedEarlyTabs, "size", 1);
+ instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "foo"));
+ assert.propertyVal(instance._repliedEarlyTabs, "size", 1);
+ instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "bar"));
+ assert.propertyVal(instance._repliedEarlyTabs, "size", 0);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersistentCache.test.js b/browser/components/newtab/test/unit/lib/PersistentCache.test.js
new file mode 100644
index 0000000000..e645b8d398
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersistentCache.test.js
@@ -0,0 +1,142 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { PersistentCache } from "lib/PersistentCache.sys.mjs";
+
+describe("PersistentCache", () => {
+ let fakeIOUtils;
+ let fakePathUtils;
+ let cache;
+ let filename = "cache.json";
+ let consoleErrorStub;
+ let globals;
+ let sandbox;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ fakeIOUtils = {
+ writeJSON: sinon.stub().resolves(0),
+ readJSON: sinon.stub().resolves({}),
+ };
+ fakePathUtils = {
+ join: sinon.stub().returns(filename),
+ localProfileDir: "/",
+ };
+ consoleErrorStub = sandbox.stub();
+ globals.set("console", { error: consoleErrorStub });
+ globals.set("IOUtils", fakeIOUtils);
+ globals.set("PathUtils", fakePathUtils);
+
+ cache = new PersistentCache(filename);
+ });
+ afterEach(() => {
+ globals.restore();
+ sandbox.restore();
+ });
+
+ describe("#get", () => {
+ it("tries to read the file", async () => {
+ await cache.get("foo");
+ assert.calledOnce(fakeIOUtils.readJSON);
+ });
+ it("doesnt try to read the file if it was already loaded", async () => {
+ await cache._load();
+ fakeIOUtils.readJSON.resetHistory();
+ await cache.get("foo");
+ assert.notCalled(fakeIOUtils.readJSON);
+ });
+ it("should catch and report errors", async () => {
+ fakeIOUtils.readJSON.rejects(new SyntaxError("Failed to parse JSON"));
+ await cache._load();
+ assert.calledOnce(consoleErrorStub);
+
+ cache._cache = undefined;
+ consoleErrorStub.resetHistory();
+
+ fakeIOUtils.readJSON.rejects(
+ new DOMException("IOUtils shutting down", "AbortError")
+ );
+ await cache._load();
+ assert.calledOnce(consoleErrorStub);
+
+ cache._cache = undefined;
+ consoleErrorStub.resetHistory();
+
+ fakeIOUtils.readJSON.rejects(
+ new DOMException("File not found", "NotFoundError")
+ );
+ await cache._load();
+ assert.notCalled(consoleErrorStub);
+ });
+ it("returns data for a given cache key", async () => {
+ fakeIOUtils.readJSON.resolves({ foo: "bar" });
+ let value = await cache.get("foo");
+ assert.equal(value, "bar");
+ });
+ it("returns undefined for a cache key that doesn't exist", async () => {
+ let value = await cache.get("baz");
+ assert.equal(value, undefined);
+ });
+ it("returns all the data if no cache key is specified", async () => {
+ fakeIOUtils.readJSON.resolves({ foo: "bar" });
+ let value = await cache.get();
+ assert.deepEqual(value, { foo: "bar" });
+ });
+ });
+
+ describe("#set", () => {
+ it("tries to read the file on the first set", async () => {
+ await cache.set("foo", { x: 42 });
+ assert.calledOnce(fakeIOUtils.readJSON);
+ });
+ it("doesnt try to read the file if it was already loaded", async () => {
+ cache = new PersistentCache(filename, true);
+ await cache._load();
+ fakeIOUtils.readJSON.resetHistory();
+ await cache.set("foo", { x: 42 });
+ assert.notCalled(fakeIOUtils.readJSON);
+ });
+ it("sets a string value", async () => {
+ const key = "testkey";
+ const value = "testvalue";
+ await cache.set(key, value);
+ const cachedValue = await cache.get(key);
+ assert.equal(cachedValue, value);
+ });
+ it("sets an object value", async () => {
+ const key = "testkey";
+ const value = { x: 1, y: 2, z: 3 };
+ await cache.set(key, value);
+ const cachedValue = await cache.get(key);
+ assert.deepEqual(cachedValue, value);
+ });
+ it("writes the data to file", async () => {
+ const key = "testkey";
+ const value = { x: 1, y: 2, z: 3 };
+
+ await cache.set(key, value);
+ assert.calledOnce(fakeIOUtils.writeJSON);
+ assert.calledWith(
+ fakeIOUtils.writeJSON,
+ filename,
+ { [[key]]: value },
+ { tmpPath: `${filename}.tmp` }
+ );
+ });
+ it("throws when failing to get file path", async () => {
+ Object.defineProperty(fakePathUtils, "localProfileDir", {
+ get() {
+ throw new Error();
+ },
+ });
+
+ let rejected = false;
+ try {
+ await cache.set("key", "val");
+ } catch (error) {
+ rejected = true;
+ }
+
+ assert(rejected);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js
new file mode 100644
index 0000000000..0751cafb4f
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js
@@ -0,0 +1,95 @@
+import { NaiveBayesTextTagger } from "lib/PersonalityProvider/NaiveBayesTextTagger.jsm";
+import {
+ tokenize,
+ toksToTfIdfVector,
+} from "lib/PersonalityProvider/Tokenize.jsm";
+
+const EPSILON = 0.00001;
+
+describe("Naive Bayes Tagger", () => {
+ describe("#tag", () => {
+ let model = {
+ model_type: "nb",
+ positive_class_label: "military",
+ positive_class_id: 0,
+ positive_class_threshold_log_prob: -0.5108256237659907,
+ classes: [
+ {
+ log_prior: -0.6881346387364013,
+ feature_log_probs: [
+ -6.2149425847276, -6.829869141665873, -7.124856122235796,
+ -7.116661287797188, -6.694751331313906, -7.11798266787003,
+ -6.5094904366004185, -7.1639509366900604, -7.218981434452414,
+ -6.854842907887801, -7.080328841624584,
+ ],
+ },
+ {
+ log_prior: -0.6981849745899025,
+ feature_log_probs: [
+ -7.0575941199203465, -6.632333513597953, -7.382756370680115,
+ -7.1160793981275905, -8.467120918791892, -8.369201274990882,
+ -8.518506617006922, -7.015756380369387, -7.739036845511857,
+ -9.748294397894645, -3.9353548206941955,
+ ],
+ },
+ ],
+ vocab_idfs: {
+ deal: [0, 5.5058519847862275],
+ easy: [1, 5.5058519847862275],
+ tanks: [2, 5.601162164590552],
+ sites: [3, 5.957837108529285],
+ care: [4, 5.957837108529285],
+ needs: [5, 5.824305715904762],
+ finally: [6, 5.706522680248379],
+ super: [7, 5.264689927969339],
+ heard: [8, 5.5058519847862275],
+ reached: [9, 5.957837108529285],
+ words: [10, 5.070533913528382],
+ },
+ };
+ let instance = new NaiveBayesTextTagger(model, toksToTfIdfVector);
+
+ let testCases = [
+ {
+ input: "Finally! Super easy care for your tanks!",
+ expected: {
+ label: "military",
+ logProb: -0.16299510296630082,
+ confident: true,
+ },
+ },
+ {
+ input: "heard",
+ expected: {
+ label: "military",
+ logProb: -0.4628170738373294,
+ confident: false,
+ },
+ },
+ {
+ input: "words",
+ expected: {
+ label: null,
+ logProb: -0.04258339303757985,
+ confident: false,
+ },
+ },
+ ];
+
+ let checkTag = tc => {
+ let actual = instance.tagTokens(tokenize(tc.input));
+ it(`should tag ${tc.input} with ${tc.expected.label}`, () => {
+ assert.equal(tc.expected.label, actual.label);
+ });
+ it(`should give ${tc.input} the correct probability`, () => {
+ let delta = Math.abs(tc.expected.logProb - actual.logProb);
+ assert.isTrue(delta <= EPSILON);
+ });
+ };
+
+ // RELEASE THE TESTS!
+ for (let tc of testCases) {
+ checkTag(tc);
+ }
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js
new file mode 100644
index 0000000000..fb3abb1367
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js
@@ -0,0 +1,479 @@
+import { NmfTextTagger } from "lib/PersonalityProvider/NmfTextTagger.jsm";
+import {
+ tokenize,
+ toksToTfIdfVector,
+} from "lib/PersonalityProvider/Tokenize.jsm";
+
+const EPSILON = 0.00001;
+
+describe("NMF Tagger", () => {
+ describe("#tag", () => {
+ // The numbers in this model were pulled from existing trained model.
+ let model = {
+ document_topic: {
+ environment: [
+ 0.05313956429537541, 0.07314019377743895, 0.03247190024863182,
+ 0.016189529772591395, 0.003812317145412572, 0.03863075834647775,
+ 0.007495425135831521, 0.005100298003919777, 0.005245622179405364,
+ 0.036196010766427554, 0.02189970342121833, 0.03514130992119014,
+ 0.001248114096050196, 0.0030908722594824665, 0.0023874256586350626,
+ 0.008533674814792993, 0.0009424690250135675, 0.01603124888144218,
+ 0.00752822798092765, 0.0039046678154748796, 0.03521776907836766,
+ 0.00614546613169027, 0.0008272200196643818, 0.01405638079154697,
+ 0.001990670259485496, 0.002803666919676377, 0.013841677883061631,
+ 0.004093362693745272, 0.009310678536276432, 0.006158920150866703,
+ 0.006821027337091937, 0.002712031105462971, 0.009093298611644996,
+ 0.014642160500331744, 0.0067239941045715386, 0.007150418784462898,
+ 0.0064652818600521265, 0.0006735690394489199, 0.02063188588742841,
+ 0.003213083349614106, 0.0031998068360970093, 0.00264520606931871,
+ 0.008854824468146531, 0.0024170562884908786, 0.0013705390639746128,
+ 0.0030575940757273288, 0.010417378215688392, 0.002356164040132228,
+ 0.0026710154645455007, 0.0007295327370144145, 0.0585307418954327,
+ 0.0037987763460599574, 0.003199095437138493, 0.004368800434950577,
+ 0.005087168372751965, 0.0011100904433965942, 0.01700096791869979,
+ 0.01929226435023826, 0.010536397909643058, 0.001734999985783697,
+ 0.003852807194017686, 0.007916805773686475, 0.028375307444815964,
+ 0.0012422599635274355, 0.0009298594944844238, 0.02095410849846837,
+ 0.0017269844428419192, 0.002152880993141985, 0.0030226616228192387,
+ 0.004804812297400959, 0.0012383636748462198, 0.006991278216261148,
+ 0.0013747035300597538, 0.002041541234639563, 0.012076270996247411,
+ 0.006643837514421182, 0.003974012776560734, 0.015794539051705442,
+ 0.007601190171659186, 0.016474925942594837, 0.002729423078513777,
+ 0.007635146179880609, 0.013457547041824648, 0.0007592338429017099,
+ 0.002947096673767141, 0.006371935735541048, 0.003356178481568716,
+ 0.00451933490245723, 0.0019006306992329104, 0.013048046603391707,
+ 0.023541628496101297, 0.027659066125377194, 0.002312727786055524,
+ 0.0014189157259186062, 0.01963766030236683, 0.0026014761547439634,
+ 0.002333697870992923, 0.003401734295211338, 0.002522073778255918,
+ 0.0015769783084977752,
+ ],
+ space: [
+ 0.045976774394786174, 0.04386532305052323, 0.03346748817597193,
+ 0.008498345884036708, 0.005802390890667938, 0.0017673346473868704,
+ 0.00468037374691276, 0.0036807899985757367, 0.0034951488381868424,
+ 0.015073756869093244, 0.006784747891785806, 0.03069702365741547,
+ 0.004945214461908244, 0.002527030239506901, 0.0012201743197690308,
+ 0.010191409658936534, 0.0013882500616525532, 0.014559679471816162,
+ 0.005308140956577744, 0.002067005832569046, 0.006092496689239475,
+ 0.0029308442356851265, 0.0006407392160713908, 0.01669972147417425,
+ 0.0018920321527190246, 0.002436089537269062, 0.05542174181989591,
+ 0.006448761215865303, 0.012804154851567844, 0.014553974971946687,
+ 0.004927456148063145, 0.006085620881900181, 0.011626122370522652,
+ 0.002994267915422563, 0.0038291031528493898, 0.006987917175322377,
+ 0.00719289436611732, 0.0008398926158042337, 0.019068654506361523,
+ 0.004453895285397824, 0.00401164781243836, 0.0031309255764704544,
+ 0.013210118660087334, 0.0015542151889036313, 0.0013951089590218057,
+ 0.002790924761398501, 0.008739250167366135, 0.0027834569638271025,
+ 0.09198161284531065, 0.0019488047187835441, 0.001739971582806101,
+ 0.005113637251322287, 0.12140493794373561, 0.005535368890812829,
+ 0.004198222617607059, 0.0010670629105233682, 0.005298717616708989,
+ 0.0048291586850982855, 0.005140125537186181, 0.0011663683373124493,
+ 0.0024499638218810943, 0.012532772497286819, 0.0015564613278042862,
+ 0.0012252899339204029, 0.0005095187051357676, 0.0035442657060978655,
+ 0.014030578705118285, 0.0017653534252553718, 0.004026729875153457,
+ 0.004002067082856801, 0.00809773970333208, 0.017160384509220625,
+ 0.002981945110677171, 0.0018338446554387704, 0.0031886913904107484,
+ 0.004654622711785796, 0.0053886727821435415, 0.009023511029300392,
+ 0.005246967669202147, 0.022806469628558337, 0.0035142224878495355,
+ 0.006793295047927272, 0.017396620747821886, 0.000922278971300957,
+ 0.001695889413253992, 0.007015197552957029, 0.003908581792868586,
+ 0.010136260994789877, 0.0032880552208979508, 0.0039712539426523625,
+ 0.009672046620728448, 0.007290428293346, 0.0017814796852793386,
+ 0.0005388988974780036, 0.013936726486762537, 0.003427738251710856,
+ 0.002206664729558829, 0.05072392472622557, 0.004424158921356747,
+ 0.0003680061331891622,
+ ],
+ biology: [
+ 0.054433533850037796, 0.039689474154513994, 0.027661000660240884,
+ 0.021655563357213067, 0.007862624595639219, 0.006280655377019006,
+ 0.013407714984668861, 0.004038592819712647, 0.009652765217013826,
+ 0.0011353987945632667, 0.00925298156804724, 0.004870163054917538,
+ 0.04911204317171355, 0.006921538451191124, 0.004003624507234068,
+ 0.016600722822360296, 0.002179735905957767, 0.010801493818182368,
+ 0.00918922860910538, 0.022115576350545514, 0.0027720850555002148,
+ 0.003290714340925284, 0.0006359939927595049, 0.020564054347194806,
+ 0.019590591011010666, 0.0029008397180383077, 0.030414664509122412,
+ 0.002864704837438281, 0.030933936414333993, 0.00222576969791357,
+ 0.007077232390623289, 0.005876547862506722, 0.016917705934608753,
+ 0.016466207380001166, 0.006648808144677746, 0.017876914915160164,
+ 0.008216930648675583, 0.0026813239798232098, 0.012171904585413245,
+ 0.012319763594831614, 0.003909608203628946, 0.003205613981613637,
+ 0.027729523430009183, 0.0019938396819227074, 0.002752482544417343,
+ 0.0016746657427111145, 0.019564250521109314, 0.027250898086440583,
+ 0.000954251437229793, 0.0020431321836649734, 0.0014636128217840221,
+ 0.006821766389705783, 0.003272989792090916, 0.011086677363737012,
+ 0.0044279892365732595, 0.0029213721398486203, 0.013081117655947345,
+ 0.012102962176204816, 0.0029165848047082825, 0.002363073972325097,
+ 0.0028567640089643695, 0.013692951578614878, 0.0013189478722657382,
+ 0.0030662419379415885, 0.001688218039583749, 0.0007806438728749603,
+ 0.025458033834110355, 0.009584308792578437, 0.0033243840056188263,
+ 0.0068361098488461045, 0.005178034666939756, 0.006831575853694424,
+ 0.010170774789130092, 0.004639315532453418, 0.00655511046953238,
+ 0.005661100806175219, 0.006238755352678196, 0.023282136482285103,
+ 0.007790828526461584, 0.011840304456780202, 0.0021953903460442225,
+ 0.011205225479328193, 0.01665869590158306, 0.0009257333679666402,
+ 0.0032380769616003604, 0.007379754534437712, 0.01804771060116468,
+ 0.02540492978451049, 0.0027900782593570507, 0.0029721824342474694,
+ 0.005666888959879564, 0.003629523931553047, 0.0017838703067849428,
+ 0.004996486217852931, 0.006086510142627035, 0.0023570031997685236,
+ 0.002718397814380002, 0.003908858478916721, 0.02080129902865465,
+ 0.005591305783253238,
+ ],
+ },
+ topic_word: [
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.003173633134427233, 0.0, 0.0,
+ 0.0019409914586816176, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 5.135548639746091e-5, 0.0, 0.0, 0.0,
+ 0.00015384770766669982,
+ ],
+ [
+ 0.0, 0.0, 0.0005001441880557176, 0.0, 0.0, 0.0012069823147301646,
+ 0.02401141538644239, 8.831990149479376e-5, 0.001813504147854849, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0003577161362340021, 0.0005744157863408606,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.002662246533243532, 0.0, 0.0,
+ 0.0008394369973758684, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 4.768637450522633e-5, 0.0, 0.0, 0.0, 0.0, 0.0010421065429755969,
+ 0.0, 0.0, 2.3210938729937306e-5,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0006034363278588148,
+ 0.001690622339085902, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.004257728522853072, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0007238839225620208, 0.0, 0.0, 0.0, 0.0, 0.0009507496006759083,
+ 0.0012635532859311572, 0.0, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.2699264109324263e-5,
+ 0.00032868342552128994, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0011157667743487598, 0.001278875789622101,
+ 9.011724853181247e-6, 0.0, 3.22069766200917e-5, 0.004124963644732435,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00011961487736485771],
+ [0.0, 0.0, 0.0, 5.734703813314615e-5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 4.0340264022466226e-5, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.00039701897786057513, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.19635202968946042, 0.0, 0.0008873887898279083, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 1.552973162326247e-5, 0.0,
+ 0.002284331845105356, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.005561738919282601, 0.0, 0.0, 0.0, 0.010700323065082812,
+ 0.0, 0.0005795117202094265, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0005085828329663487, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.029261090049475084, 0.0020864946050332834,
+ 0.0018513709831557076, 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0008328286790309667, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0013227647245223537, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0024010554774254685, 5.357245317969706e-5, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0014484032312145462, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0012081428144960678, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.000616488580813398, 0.0, 0.0, 0.0017954524796671627, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0006660554263924299, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0011891151421092303, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0024885434472066534, 0.0,
+ 0.0010165824086743897, 0.0, 0.0,
+ ],
+ [
+ 0.0, 5.692292246819767e-5, 0.0, 0.0, 0.001006289633741549, 0.0, 0.0,
+ 0.001897882990870404, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00010646854330751878, 0.0,
+ 0.0013480243353754932, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0002608785715957589, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0010620422134845085, 0.0, 0.0,
+ 0.0002032215308376943, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0008928062238389307, 0.0, 0.0,
+ 5.727265080002417e-5, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.06061253593083364, 0.0, 0.02739898181912798, 0.0, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0014338134220455178, 0.0,
+ 0.0011276871850520397, 0.002840121913315777,
+ ],
+ [0.0008014293374641945, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.000345858724152025, 0.013078498367906305, 0.0,
+ 0.002815596608197659, 0.0, 0.0, 0.0030778986683343023, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0010177321509216356, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.00015333347872060042, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0009655934464519347, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0008542046515290346, 0.0, 0.0,
+ 0.00016472517230317488, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0007759590139787148,
+ 0.0037535348789227703, 0.0007205740927611773,
+ ],
+ [
+ 0.0, 0.0, 0.0010313963595627862, 0.0, 0.0, 0.0, 0.0, 0.0,
+ 0.0069665132800572115, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0006880323929924655, 9.207429290830475e-5,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0008404475484102756, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.00016603822882009137, 0.0, 0.0, 0.0,
+ 0.0004386724451378034,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.003971386830918022, 0.0, 0.0, 0.0, 0.0],
+ [0.000983926199078037, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.001299108775819868, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.16326515307916822, 0.0, 0.0, 0.0, 0.0, 0.0028677496385613155,
+ 0.023677620702293598, 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 5.737710913345495e-6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0002081792662367579, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
+ 0.0002840163488982256,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0005021534925351664, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.001057424953719077, 0.0,
+ 0.003578658690485632, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.00022950619982206556,
+ 0.0018791783657735252, 0.0008530683004027156, 4.5513911743540586e-5,
+ 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0045523319463242765, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0006160628426134845, 0.0, 0.0023393152617350653,
+ 0.0, 0.0, 0.0012979890699731222,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.003391399407584813, 0.0, 0.0, 0.000719659722017165, 0.0,
+ 0.004722518573572638, 0.002758841738663124, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.002127862313876461, 0.0, 0.005031998155190167,
+ 0.0, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.00055401373160389, 0.0, 0.0, 0.000333325450244618,
+ 0.0017824446558959168, 0.0011398506826041158, 0.0,
+ 0.0006366915431430632,
+ ],
+ [
+ 0.0, 0.21687336139378274, 0.0, 0.0, 0.0, 0.0030345303266644387, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0012637173523723526, 0.0,
+ 0.0010158476831041915, 0.0035425832276585615, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0015451984659512325, 0.019909953764629045,
+ 0.0013484737840911303, 0.0033472098053086113, 0.0016951819626954759,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00015923419851654453, 0.0,
+ 0.0024056492047359367,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.01305313280419075,
+ 0.00014197157780982973, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.000746430999979358, 0.0,
+ 0.0010041202546700189, 0.004557016648181857, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00021372865758801545,
+ 0.00025925151316940747, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.001658746582791234, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.00973640859923001, 0.0012404719999980969,
+ 0.0006365355864806626, 0.0008291013715577852, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.001473459191608214, 0.0, 0.0,
+ 0.0009195459918865811, 0.002012929485852207,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0005850456523130979, 0.0,
+ 0.00014396718214395852, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0011858302272740567, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0046803403116507545, 0.002083219444498354, 0.0,
+ 0.0, 0.0, 0.006104495765365948,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.005456944646675863, 0.0,
+ 0.00011428354610339084, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0013384597578988894, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0018450592044551373, 0.0,
+ 0.005182965872305058, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0003041074021307749, 0.0,
+ 0.0020827735275448823, 0.0, 0.0008494429669380388,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ ],
+ vocab_idfs: {
+ blood: [0, 5.0948820521571045],
+ earth: [1, 4.2248041634380815],
+ rocket: [2, 5.666668375712782],
+ brain: [3, 4.616846251214104],
+ mars: [4, 6.226284163648205],
+ nothing: [5, 5.270772718620769],
+ nada: [6, 4.815297189937943],
+ star: [7, 6.38880309314598],
+ zilch: [8, 5.889811927026992],
+ soil: [9, 7.14257489552236],
+ },
+ };
+
+ let instance = new NmfTextTagger(model, toksToTfIdfVector);
+
+ let testCases = [
+ {
+ input: "blood is in the brain",
+ expected: {
+ environment: 0.00037336337061919943,
+ space: 0.0003307690554984028,
+ biology: 0.0026549079818439627,
+ },
+ },
+
+ {
+ input: "rocket to the star",
+ expected: {
+ environment: 0.0002855180592590448,
+ space: 0.004006242743506598,
+ biology: 0.0003094182371360131,
+ },
+ },
+ {
+ input: "rocket to the star mars",
+ expected: {
+ environment: 0.0004180326651780644,
+ space: 0.003844259295376754,
+ biology: 0.0003135623817729136,
+ },
+ },
+ {
+ input: "rocket rocket rocket",
+ expected: {
+ environment: 0.00033052002469507015,
+ space: 0.007519787053895712,
+ biology: 0.00031862864995569246,
+ },
+ },
+ {
+ input: "nothing nada rocket",
+ expected: {
+ environment: 0.0008597524218029812,
+ space: 0.0035401031629944506,
+ biology: 0.000950627767326667,
+ },
+ },
+ {
+ input: "rocket",
+ expected: {
+ environment: 0.00033052002469507015,
+ space: 0.007519787053895712,
+ biology: 0.00031862864995569246,
+ },
+ },
+ {
+ input: "this sentence is out of vocabulary",
+ expected: {
+ environment: 0.0,
+ space: 0.0,
+ biology: 0.0,
+ },
+ },
+ {
+ input: "this sentence is out of vocabulary except for rocket",
+ expected: {
+ environment: 0.00033052002469507015,
+ space: 0.007519787053895712,
+ biology: 0.00031862864995569246,
+ },
+ },
+ ];
+
+ let checkTag = tc => {
+ let actual = instance.tagTokens(tokenize(tc.input));
+ it(`should score ${tc.input} correctly`, () => {
+ Object.keys(actual).forEach(tag => {
+ let delta = Math.abs(tc.expected[tag] - actual[tag]);
+ assert.isTrue(delta <= EPSILON);
+ });
+ });
+ };
+
+ // RELEASE THE TESTS!
+ for (let tc of testCases) {
+ checkTag(tc);
+ }
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js
new file mode 100644
index 0000000000..833a9d5a7c
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js
@@ -0,0 +1,356 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm";
+
+describe("Personality Provider", () => {
+ let instance;
+ let RemoteSettingsStub;
+ let RemoteSettingsOnStub;
+ let RemoteSettingsOffStub;
+ let RemoteSettingsGetStub;
+ let sandbox;
+ let globals;
+ let baseURLStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+
+ RemoteSettingsOnStub = sandbox.stub().returns();
+ RemoteSettingsOffStub = sandbox.stub().returns();
+ RemoteSettingsGetStub = sandbox.stub().returns([]);
+
+ RemoteSettingsStub = name => ({
+ get: RemoteSettingsGetStub,
+ on: RemoteSettingsOnStub,
+ off: RemoteSettingsOffStub,
+ });
+
+ sinon.spy(global, "BasePromiseWorker");
+ sinon.spy(global.BasePromiseWorker.prototype, "post");
+
+ baseURLStub = "https://baseattachmentsurl";
+ global.fetch = async server => ({
+ ok: true,
+ json: async () => {
+ if (server === "bogus://foo/") {
+ return { capabilities: { attachments: { base_url: baseURLStub } } };
+ }
+ return {};
+ },
+ });
+ globals.set("RemoteSettings", RemoteSettingsStub);
+
+ instance = new PersonalityProvider();
+ instance.interestConfig = {
+ history_item_builder: "history_item_builder",
+ history_required_fields: ["a", "b", "c"],
+ interest_finalizer: "interest_finalizer",
+ item_to_rank_builder: "item_to_rank_builder",
+ item_ranker: "item_ranker",
+ interest_combiner: "interest_combiner",
+ };
+ });
+ afterEach(() => {
+ sinon.restore();
+ sandbox.restore();
+ globals.restore();
+ });
+ describe("#personalityProviderWorker", () => {
+ it("should create a new promise worker on first call", async () => {
+ const { personalityProviderWorker } = instance;
+ assert.calledOnce(global.BasePromiseWorker);
+ assert.isDefined(personalityProviderWorker);
+ });
+ it("should cache _personalityProviderWorker on first call", async () => {
+ instance._personalityProviderWorker = null;
+ const { personalityProviderWorker } = instance;
+ assert.isDefined(instance._personalityProviderWorker);
+ assert.isDefined(personalityProviderWorker);
+ });
+ it("should use old promise worker on second call", async () => {
+ let { personalityProviderWorker } = instance;
+ personalityProviderWorker = instance.personalityProviderWorker;
+ assert.calledOnce(global.BasePromiseWorker);
+ assert.isDefined(personalityProviderWorker);
+ });
+ });
+ describe("#_getBaseAttachmentsURL", () => {
+ it("should return a fresh value", async () => {
+ await instance._getBaseAttachmentsURL();
+ assert.equal(instance._baseAttachmentsURL, baseURLStub);
+ });
+ it("should return a cached value", async () => {
+ const cachedURL = "cached";
+ instance._baseAttachmentsURL = cachedURL;
+ await instance._getBaseAttachmentsURL();
+ assert.equal(instance._baseAttachmentsURL, cachedURL);
+ });
+ });
+ describe("#setup", () => {
+ it("should setup two sync attachments", () => {
+ sinon.spy(instance, "setupSyncAttachment");
+ instance.setup();
+ assert.calledTwice(instance.setupSyncAttachment);
+ });
+ });
+ describe("#teardown", () => {
+ it("should teardown two sync attachments", () => {
+ sinon.spy(instance, "teardownSyncAttachment");
+ instance.teardown();
+ assert.calledTwice(instance.teardownSyncAttachment);
+ });
+ it("should terminate worker", () => {
+ const terminateStub = sandbox.stub().returns();
+ instance._personalityProviderWorker = {
+ terminate: terminateStub,
+ };
+ instance.teardown();
+ assert.calledOnce(terminateStub);
+ });
+ });
+ describe("#setupSyncAttachment", () => {
+ it("should call remote settings on twice for setupSyncAttachment", () => {
+ assert.calledTwice(RemoteSettingsOnStub);
+ });
+ });
+ describe("#teardownSyncAttachment", () => {
+ it("should call remote settings off for teardownSyncAttachment", () => {
+ instance.teardownSyncAttachment();
+ assert.calledOnce(RemoteSettingsOffStub);
+ });
+ });
+ describe("#onSync", () => {
+ it("should call worker onSync", () => {
+ instance.onSync();
+ assert.calledWith(global.BasePromiseWorker.prototype.post, "onSync");
+ });
+ });
+ describe("#getAttachment", () => {
+ it("should call worker onSync", () => {
+ instance.getAttachment();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "getAttachment"
+ );
+ });
+ });
+ describe("#getRecipe", () => {
+ it("should call worker getRecipe and remote settings get", async () => {
+ RemoteSettingsGetStub = sandbox.stub().returns([
+ {
+ key: 1,
+ },
+ ]);
+ sinon.spy(instance, "getAttachment");
+ RemoteSettingsStub = name => ({
+ get: RemoteSettingsGetStub,
+ on: RemoteSettingsOnStub,
+ off: RemoteSettingsOffStub,
+ });
+ globals.set("RemoteSettings", RemoteSettingsStub);
+
+ const result = await instance.getRecipe();
+ assert.calledOnce(RemoteSettingsGetStub);
+ assert.calledOnce(instance.getAttachment);
+ assert.equal(result.recordKey, 1);
+ });
+ });
+ describe("#fetchHistory", () => {
+ it("should return a history object for fetchHistory", async () => {
+ const history = await instance.fetchHistory(["requiredColumn"], 1, 1);
+ assert.equal(
+ history.sql,
+ `SELECT url, title, visit_count, frecency, last_visit_date, description\n FROM moz_places\n WHERE last_visit_date >= 1000000\n AND last_visit_date < 1000000 AND IFNULL(requiredColumn, '') <> '' LIMIT 30000`
+ );
+ assert.equal(history.options.columns.length, 1);
+ assert.equal(Object.keys(history.options.params).length, 0);
+ });
+ });
+ describe("#getHistory", () => {
+ it("should return an empty array", async () => {
+ instance.interestConfig = {
+ history_required_fields: [],
+ };
+ const result = await instance.getHistory();
+ assert.equal(result.length, 0);
+ });
+ it("should call fetchHistory", async () => {
+ sinon.spy(instance, "fetchHistory");
+ await instance.getHistory();
+ });
+ });
+ describe("#setBaseAttachmentsURL", () => {
+ it("should call worker setBaseAttachmentsURL", async () => {
+ await instance.setBaseAttachmentsURL();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "setBaseAttachmentsURL"
+ );
+ });
+ });
+ describe("#setInterestConfig", () => {
+ it("should call worker setInterestConfig", async () => {
+ await instance.setInterestConfig();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "setInterestConfig"
+ );
+ });
+ });
+ describe("#setInterestVector", () => {
+ it("should call worker setInterestVector", async () => {
+ await instance.setInterestVector();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "setInterestVector"
+ );
+ });
+ });
+ describe("#fetchModels", () => {
+ it("should call worker fetchModels and remote settings get", async () => {
+ await instance.fetchModels();
+ assert.calledOnce(RemoteSettingsGetStub);
+ assert.calledWith(global.BasePromiseWorker.prototype.post, "fetchModels");
+ });
+ });
+ describe("#generateTaggers", () => {
+ it("should call worker generateTaggers", async () => {
+ await instance.generateTaggers();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "generateTaggers"
+ );
+ });
+ });
+ describe("#generateRecipeExecutor", () => {
+ it("should call worker generateRecipeExecutor", async () => {
+ await instance.generateRecipeExecutor();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "generateRecipeExecutor"
+ );
+ });
+ });
+ describe("#createInterestVector", () => {
+ it("should call worker createInterestVector", async () => {
+ await instance.createInterestVector();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "createInterestVector"
+ );
+ });
+ });
+ describe("#init", () => {
+ it("should return early if setInterestConfig fails", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ instance.interestConfig = null;
+ const callback = globals.sandbox.stub();
+ await instance.init(callback);
+ assert.notCalled(callback);
+ });
+ it("should return early if fetchModels fails", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ sandbox.stub(instance, "fetchModels").resolves({
+ ok: false,
+ });
+ const callback = globals.sandbox.stub();
+ await instance.init(callback);
+ assert.notCalled(callback);
+ });
+ it("should return early if createInterestVector fails", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ sandbox.stub(instance, "fetchModels").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "generateRecipeExecutor").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "createInterestVector").resolves({
+ ok: false,
+ });
+ const callback = globals.sandbox.stub();
+ await instance.init(callback);
+ assert.notCalled(callback);
+ });
+ it("should call callback on successful init", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ sandbox.stub(instance, "fetchModels").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "generateRecipeExecutor").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "createInterestVector").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "setInterestVector").resolves();
+ const callback = globals.sandbox.stub();
+ await instance.init(callback);
+ assert.calledOnce(callback);
+ assert.isTrue(instance.initialized);
+ });
+ it("should do generic init stuff when calling init with no cache", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ sandbox.stub(instance, "fetchModels").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "generateRecipeExecutor").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "createInterestVector").resolves({
+ ok: true,
+ interestVector: "interestVector",
+ });
+ sandbox.stub(instance, "setInterestVector").resolves();
+ await instance.init();
+ assert.calledOnce(instance.setBaseAttachmentsURL);
+ assert.calledOnce(instance.setInterestConfig);
+ assert.calledOnce(instance.fetchModels);
+ assert.calledOnce(instance.generateRecipeExecutor);
+ assert.calledOnce(instance.createInterestVector);
+ assert.calledOnce(instance.setInterestVector);
+ });
+ });
+ describe("#calculateItemRelevanceScore", () => {
+ it("should return score for uninitialized provider", async () => {
+ instance.initialized = false;
+ assert.equal(
+ await instance.calculateItemRelevanceScore({ item_score: 2 }),
+ 2
+ );
+ });
+ it("should return score for initialized provider", async () => {
+ instance.initialized = true;
+
+ instance._personalityProviderWorker = {
+ post: (postName, [item]) => ({
+ rankingVector: { score: item.item_score },
+ }),
+ };
+
+ assert.equal(
+ await instance.calculateItemRelevanceScore({ item_score: 2 }),
+ 2
+ );
+ });
+ it("should post calculateItemRelevanceScore to PersonalityProviderWorker", async () => {
+ instance.initialized = true;
+ await instance.calculateItemRelevanceScore({ item_score: 2 });
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "calculateItemRelevanceScore"
+ );
+ });
+ });
+ describe("#getScores", () => {
+ it("should return correct data for getScores", () => {
+ const scores = instance.getScores();
+ assert.isDefined(scores.interestConfig);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js
new file mode 100644
index 0000000000..6dd483ae70
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js
@@ -0,0 +1,456 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { PersonalityProviderWorker } from "lib/PersonalityProvider/PersonalityProviderWorkerClass.jsm";
+import {
+ tokenize,
+ toksToTfIdfVector,
+} from "lib/PersonalityProvider/Tokenize.jsm";
+import { RecipeExecutor } from "lib/PersonalityProvider/RecipeExecutor.jsm";
+import { NmfTextTagger } from "lib/PersonalityProvider/NmfTextTagger.jsm";
+import { NaiveBayesTextTagger } from "lib/PersonalityProvider/NaiveBayesTextTagger.jsm";
+
+describe("Personality Provider Worker Class", () => {
+ let instance;
+ let globals;
+ let sandbox;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ globals.set("tokenize", tokenize);
+ globals.set("toksToTfIdfVector", toksToTfIdfVector);
+ globals.set("NaiveBayesTextTagger", NaiveBayesTextTagger);
+ globals.set("NmfTextTagger", NmfTextTagger);
+ globals.set("RecipeExecutor", RecipeExecutor);
+ instance = new PersonalityProviderWorker();
+
+ // mock the RecipeExecutor
+ instance.recipeExecutor = {
+ executeRecipe: (item, recipe) => {
+ if (recipe === "history_item_builder") {
+ if (item.title === "fail") {
+ return null;
+ }
+ return {
+ title: item.title,
+ score: item.frecency,
+ type: "history_item",
+ };
+ } else if (recipe === "interest_finalizer") {
+ return {
+ title: item.title,
+ score: item.score * 100,
+ type: "interest_vector",
+ };
+ } else if (recipe === "item_to_rank_builder") {
+ if (item.title === "fail") {
+ return null;
+ }
+ return {
+ item_title: item.title,
+ item_score: item.score,
+ type: "item_to_rank",
+ };
+ } else if (recipe === "item_ranker") {
+ if (item.title === "fail" || item.item_title === "fail") {
+ return null;
+ }
+ return {
+ title: item.title,
+ score: item.item_score * item.score,
+ type: "ranked_item",
+ };
+ }
+ return null;
+ },
+ executeCombinerRecipe: (item1, item2, recipe) => {
+ if (recipe === "interest_combiner") {
+ if (
+ item1.title === "combiner_fail" ||
+ item2.title === "combiner_fail"
+ ) {
+ return null;
+ }
+ if (item1.type === undefined) {
+ item1.type = "combined_iv";
+ }
+ if (item1.score === undefined) {
+ item1.score = 0;
+ }
+ return { type: item1.type, score: item1.score + item2.score };
+ }
+ return null;
+ },
+ };
+
+ instance.interestConfig = {
+ history_item_builder: "history_item_builder",
+ history_required_fields: ["a", "b", "c"],
+ interest_finalizer: "interest_finalizer",
+ item_to_rank_builder: "item_to_rank_builder",
+ item_ranker: "item_ranker",
+ interest_combiner: "interest_combiner",
+ };
+ });
+ afterEach(() => {
+ sinon.restore();
+ sandbox.restore();
+ globals.restore();
+ });
+ describe("#setBaseAttachmentsURL", () => {
+ it("should set baseAttachmentsURL", () => {
+ instance.setBaseAttachmentsURL("url");
+ assert.equal(instance.baseAttachmentsURL, "url");
+ });
+ });
+ describe("#setInterestConfig", () => {
+ it("should set interestConfig", () => {
+ instance.setInterestConfig("config");
+ assert.equal(instance.interestConfig, "config");
+ });
+ });
+ describe("#setInterestVector", () => {
+ it("should set interestVector", () => {
+ instance.setInterestVector("vector");
+ assert.equal(instance.interestVector, "vector");
+ });
+ });
+ describe("#onSync", async () => {
+ it("should sync remote settings collection from onSync", async () => {
+ sinon.stub(instance, "deleteAttachment").resolves();
+ sinon.stub(instance, "maybeDownloadAttachment").resolves();
+
+ instance.onSync({
+ data: {
+ created: ["create-1", "create-2"],
+ updated: [
+ { old: "update-old-1", new: "update-new-1" },
+ { old: "update-old-2", new: "update-new-2" },
+ ],
+ deleted: ["delete-2", "delete-1"],
+ },
+ });
+
+ assert(instance.maybeDownloadAttachment.withArgs("create-1").calledOnce);
+ assert(instance.maybeDownloadAttachment.withArgs("create-2").calledOnce);
+ assert(
+ instance.maybeDownloadAttachment.withArgs("update-new-1").calledOnce
+ );
+ assert(
+ instance.maybeDownloadAttachment.withArgs("update-new-2").calledOnce
+ );
+
+ assert(instance.deleteAttachment.withArgs("delete-1").calledOnce);
+ assert(instance.deleteAttachment.withArgs("delete-2").calledOnce);
+ assert(instance.deleteAttachment.withArgs("update-old-1").calledOnce);
+ assert(instance.deleteAttachment.withArgs("update-old-2").calledOnce);
+ });
+ });
+ describe("#maybeDownloadAttachment", () => {
+ it("should attempt _downloadAttachment three times for maybeDownloadAttachment", async () => {
+ let existsStub;
+ let statStub;
+ let attachmentStub;
+ sinon.stub(instance, "_downloadAttachment").resolves();
+ const makeDirStub = globals.sandbox
+ .stub(global.IOUtils, "makeDirectory")
+ .resolves();
+
+ existsStub = globals.sandbox
+ .stub(global.IOUtils, "exists")
+ .resolves(true);
+
+ statStub = globals.sandbox
+ .stub(global.IOUtils, "stat")
+ .resolves({ size: "1" });
+
+ attachmentStub = {
+ attachment: {
+ filename: "file",
+ size: "1",
+ // This hash matches the hash generated from the empty Uint8Array returned by the IOUtils.read stub.
+ hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ },
+ };
+
+ await instance.maybeDownloadAttachment(attachmentStub);
+ assert.calledWith(makeDirStub, "personality-provider");
+ assert.calledOnce(existsStub);
+ assert.calledOnce(statStub);
+ assert.notCalled(instance._downloadAttachment);
+
+ existsStub.resetHistory();
+ statStub.resetHistory();
+ instance._downloadAttachment.resetHistory();
+
+ attachmentStub = {
+ attachment: {
+ filename: "file",
+ size: "2",
+ },
+ };
+
+ await instance.maybeDownloadAttachment(attachmentStub);
+ assert.calledThrice(existsStub);
+ assert.calledThrice(statStub);
+ assert.calledThrice(instance._downloadAttachment);
+
+ existsStub.resetHistory();
+ statStub.resetHistory();
+ instance._downloadAttachment.resetHistory();
+
+ attachmentStub = {
+ attachment: {
+ filename: "file",
+ size: "1",
+ // Bogus hash to trigger an update.
+ hash: "1234",
+ },
+ };
+
+ await instance.maybeDownloadAttachment(attachmentStub);
+ assert.calledThrice(existsStub);
+ assert.calledThrice(statStub);
+ assert.calledThrice(instance._downloadAttachment);
+ });
+ });
+ describe("#_downloadAttachment", () => {
+ beforeEach(() => {
+ globals.set("Uint8Array", class Uint8Array {});
+ });
+ it("should write a file from _downloadAttachment", async () => {
+ globals.set(
+ "XMLHttpRequest",
+ class {
+ constructor() {
+ this.status = 200;
+ this.response = "response!";
+ }
+ open() {}
+ setRequestHeader() {}
+ send() {}
+ }
+ );
+
+ const ioutilsWriteStub = globals.sandbox
+ .stub(global.IOUtils, "write")
+ .resolves();
+
+ await instance._downloadAttachment({
+ attachment: { location: "location", filename: "filename" },
+ });
+
+ const writeArgs = ioutilsWriteStub.firstCall.args;
+ assert.equal(writeArgs[0], "filename");
+ assert.equal(writeArgs[2].tmpPath, "filename.tmp");
+ });
+ it("should call console.error from _downloadAttachment if not valid response", async () => {
+ globals.set(
+ "XMLHttpRequest",
+ class {
+ constructor() {
+ this.status = 0;
+ this.response = "response!";
+ }
+ open() {}
+ setRequestHeader() {}
+ send() {}
+ }
+ );
+
+ const consoleErrorStub = globals.sandbox
+ .stub(console, "error")
+ .resolves();
+
+ await instance._downloadAttachment({
+ attachment: { location: "location", filename: "filename" },
+ });
+
+ assert.calledOnce(consoleErrorStub);
+ });
+ });
+ describe("#deleteAttachment", () => {
+ it("should remove attachments when calling deleteAttachment", async () => {
+ const makeDirStub = globals.sandbox
+ .stub(global.IOUtils, "makeDirectory")
+ .resolves();
+ const removeStub = globals.sandbox
+ .stub(global.IOUtils, "remove")
+ .resolves();
+ await instance.deleteAttachment({ attachment: { filename: "filename" } });
+ assert.calledOnce(makeDirStub);
+ assert.calledTwice(removeStub);
+ assert.calledWith(removeStub.firstCall, "filename", {
+ ignoreAbsent: true,
+ });
+ assert.calledWith(removeStub.secondCall, "personality-provider", {
+ ignoreAbsent: true,
+ });
+ });
+ });
+ describe("#getAttachment", () => {
+ it("should return JSON when calling getAttachment", async () => {
+ sinon.stub(instance, "maybeDownloadAttachment").resolves();
+ const readJSONStub = globals.sandbox
+ .stub(global.IOUtils, "readJSON")
+ .resolves({});
+ const record = { attachment: { filename: "filename" } };
+ let returnValue = await instance.getAttachment(record);
+
+ assert.calledOnce(readJSONStub);
+ assert.calledWith(readJSONStub, "filename");
+ assert.calledOnce(instance.maybeDownloadAttachment);
+ assert.calledWith(instance.maybeDownloadAttachment, record);
+ assert.deepEqual(returnValue, {});
+
+ readJSONStub.restore();
+ globals.sandbox.stub(global.IOUtils, "readJSON").throws("foo");
+ const consoleErrorStub = globals.sandbox
+ .stub(console, "error")
+ .resolves();
+ returnValue = await instance.getAttachment(record);
+
+ assert.calledOnce(consoleErrorStub);
+ assert.deepEqual(returnValue, {});
+ });
+ });
+ describe("#fetchModels", () => {
+ it("should return ok true", async () => {
+ sinon.stub(instance, "getAttachment").resolves();
+ const result = await instance.fetchModels([{ key: 1234 }]);
+ assert.isTrue(result.ok);
+ assert.deepEqual(instance.models, [{ recordKey: 1234 }]);
+ });
+ it("should return ok false", async () => {
+ sinon.stub(instance, "getAttachment").resolves();
+ const result = await instance.fetchModels([]);
+ assert.isTrue(!result.ok);
+ });
+ });
+ describe("#generateTaggers", () => {
+ it("should generate taggers from modelKeys", () => {
+ const modelKeys = ["nb_model_sports", "nmf_model_sports"];
+
+ instance.models = [
+ { recordKey: "nb_model_sports", model_type: "nb" },
+ {
+ recordKey: "nmf_model_sports",
+ model_type: "nmf",
+ parent_tag: "nmf_sports_parent_tag",
+ },
+ ];
+
+ instance.generateTaggers(modelKeys);
+ assert.equal(instance.taggers.nbTaggers.length, 1);
+ assert.equal(Object.keys(instance.taggers.nmfTaggers).length, 1);
+ });
+ it("should skip any models not in modelKeys", () => {
+ const modelKeys = ["nb_model_sports"];
+
+ instance.models = [
+ { recordKey: "nb_model_sports", model_type: "nb" },
+ {
+ recordKey: "nmf_model_sports",
+ model_type: "nmf",
+ parent_tag: "nmf_sports_parent_tag",
+ },
+ ];
+
+ instance.generateTaggers(modelKeys);
+ assert.equal(instance.taggers.nbTaggers.length, 1);
+ assert.equal(Object.keys(instance.taggers.nmfTaggers).length, 0);
+ });
+ it("should skip any models not defined", () => {
+ const modelKeys = ["nb_model_sports", "nmf_model_sports"];
+
+ instance.models = [{ recordKey: "nb_model_sports", model_type: "nb" }];
+ instance.generateTaggers(modelKeys);
+ assert.equal(instance.taggers.nbTaggers.length, 1);
+ assert.equal(Object.keys(instance.taggers.nmfTaggers).length, 0);
+ });
+ });
+ describe("#generateRecipeExecutor", () => {
+ it("should generate a recipeExecutor", () => {
+ instance.recipeExecutor = null;
+ instance.taggers = {};
+ instance.generateRecipeExecutor();
+ assert.isNotNull(instance.recipeExecutor);
+ });
+ });
+ describe("#createInterestVector", () => {
+ let mockHistory = [];
+ beforeEach(() => {
+ mockHistory = [
+ {
+ title: "automotive",
+ description: "something about automotive",
+ url: "http://example.com/automotive",
+ frecency: 10,
+ },
+ {
+ title: "fashion",
+ description: "something about fashion",
+ url: "http://example.com/fashion",
+ frecency: 5,
+ },
+ {
+ title: "tech",
+ description: "something about tech",
+ url: "http://example.com/tech",
+ frecency: 1,
+ },
+ ];
+ });
+ it("should gracefully handle history entries that fail", () => {
+ mockHistory.push({ title: "fail" });
+ assert.isNotNull(instance.createInterestVector(mockHistory));
+ });
+
+ it("should fail if the combiner fails", () => {
+ mockHistory.push({ title: "combiner_fail", frecency: 111 });
+ let actual = instance.createInterestVector(mockHistory);
+ assert.isNull(actual);
+ });
+
+ it("should process history, combine, and finalize", () => {
+ let actual = instance.createInterestVector(mockHistory);
+ assert.equal(actual.interestVector.score, 1600);
+ });
+ });
+ describe("#calculateItemRelevanceScore", () => {
+ it("should return null for busted item", () => {
+ assert.equal(
+ instance.calculateItemRelevanceScore({ title: "fail" }),
+ null
+ );
+ });
+ it("should return null for a busted ranking", () => {
+ instance.interestVector = { title: "fail", score: 10 };
+ assert.equal(
+ instance.calculateItemRelevanceScore({ title: "some item", score: 6 }),
+ null
+ );
+ });
+ it("should return a score, and not change with interestVector", () => {
+ instance.interestVector = { score: 10 };
+ assert.equal(
+ instance.calculateItemRelevanceScore({ score: 2 }).rankingVector.score,
+ 20
+ );
+ assert.deepEqual(instance.interestVector, { score: 10 });
+ });
+ it("should use defined personalization_models if available", () => {
+ instance.interestVector = { score: 10 };
+ const item = {
+ score: 2,
+ personalization_models: {
+ entertainment: 1,
+ },
+ };
+ assert.equal(
+ instance.calculateItemRelevanceScore(item).scorableItem.item_tags
+ .entertainment,
+ 1
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js
new file mode 100644
index 0000000000..82a1f2b77a
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js
@@ -0,0 +1,1543 @@
+import { RecipeExecutor } from "lib/PersonalityProvider/RecipeExecutor.jsm";
+import { tokenize } from "lib/PersonalityProvider/Tokenize.jsm";
+
+class MockTagger {
+ constructor(mode, tagScoreMap) {
+ this.mode = mode;
+ this.tagScoreMap = tagScoreMap;
+ }
+ tagTokens(tokens) {
+ if (this.mode === "nb") {
+ // eslint-disable-next-line prefer-destructuring
+ let tag = Object.keys(this.tagScoreMap)[0];
+ // eslint-disable-next-line prefer-destructuring
+ let prob = this.tagScoreMap[tag];
+ let conf = prob >= 0.85;
+ return {
+ label: tag,
+ logProb: Math.log(prob),
+ confident: conf,
+ };
+ }
+ return this.tagScoreMap;
+ }
+ tag(text) {
+ return this.tagTokens([text]);
+ }
+}
+
+describe("RecipeExecutor", () => {
+ let makeItem = () => {
+ let x = {
+ lhs: 2,
+ one: 1,
+ two: 2,
+ three: 3,
+ foo: "FOO",
+ bar: "BAR",
+ baz: ["one", "two", "three"],
+ qux: 42,
+ text: "This Is A_sentence.",
+ url: "http://www.wonder.example.com/dir1/dir2a-dir2b/dir3+4?key1&key2=val2&key3&%26amp=%3D3+4",
+ url2: "http://wonder.example.com/dir1/dir2a-dir2b/dir3+4?key1&key2=val2&key3&%26amp=%3D3+4",
+ map: {
+ c: 3,
+ a: 1,
+ b: 2,
+ },
+ map2: {
+ b: 2,
+ c: 3,
+ d: 4,
+ },
+ arr1: [2, 3, 4],
+ arr2: [3, 4, 5],
+ long: [3, 4, 5, 6, 7],
+ tags: {
+ a: {
+ aa: 0.1,
+ ab: 0.2,
+ ac: 0.3,
+ },
+ b: {
+ ba: 4,
+ bb: 5,
+ bc: 6,
+ },
+ },
+ bogus: {
+ a: {
+ aa: "0.1",
+ ab: "0.2",
+ ac: "0.3",
+ },
+ b: {
+ ba: "4",
+ bb: "5",
+ bc: "6",
+ },
+ },
+ zero: {
+ a: 0,
+ b: 0,
+ },
+ zaro: [0, 0],
+ };
+ return x;
+ };
+
+ let EPSILON = 0.00001;
+
+ let instance = new RecipeExecutor(
+ [
+ new MockTagger("nb", { tag1: 0.7 }),
+ new MockTagger("nb", { tag2: 0.86 }),
+ new MockTagger("nb", { tag3: 0.9 }),
+ new MockTagger("nb", { tag5: 0.9 }),
+ ],
+ {
+ tag1: new MockTagger("nmf", {
+ tag11: 0.9,
+ tag12: 0.8,
+ tag13: 0.7,
+ }),
+ tag2: new MockTagger("nmf", {
+ tag21: 0.8,
+ tag22: 0.7,
+ tag23: 0.6,
+ }),
+ tag3: new MockTagger("nmf", {
+ tag31: 0.7,
+ tag32: 0.6,
+ tag33: 0.5,
+ }),
+ tag4: new MockTagger("nmf", { tag41: 0.99 }),
+ },
+ tokenize
+ );
+ let item = null;
+
+ beforeEach(() => {
+ item = makeItem();
+ });
+
+ describe("#_assembleText", () => {
+ it("should simply copy a single string", () => {
+ assert.equal(instance._assembleText(item, ["foo"]), "FOO");
+ });
+ it("should append some strings with a space", () => {
+ assert.equal(instance._assembleText(item, ["foo", "bar"]), "FOO BAR");
+ });
+ it("should give an empty string for a missing field", () => {
+ assert.equal(instance._assembleText(item, ["missing"]), "");
+ });
+ it("should not double space an interior missing field", () => {
+ assert.equal(
+ instance._assembleText(item, ["foo", "missing", "bar"]),
+ "FOO BAR"
+ );
+ });
+ it("should splice in an array of strings", () => {
+ assert.equal(
+ instance._assembleText(item, ["foo", "baz", "bar"]),
+ "FOO one two three BAR"
+ );
+ });
+ it("should handle numbers", () => {
+ assert.equal(
+ instance._assembleText(item, ["foo", "qux", "bar"]),
+ "FOO 42 BAR"
+ );
+ });
+ });
+
+ describe("#naiveBayesTag", () => {
+ it("should understand NaiveBayesTextTagger", () => {
+ item = instance.naiveBayesTag(item, { fields: ["text"] });
+ assert.isTrue("nb_tags" in item);
+ assert.isTrue(!("tag1" in item.nb_tags));
+ assert.equal(item.nb_tags.tag2, 0.86);
+ assert.equal(item.nb_tags.tag3, 0.9);
+ assert.equal(item.nb_tags.tag5, 0.9);
+ assert.isTrue("nb_tokens" in item);
+ assert.deepEqual(item.nb_tokens, ["this", "is", "a", "sentence"]);
+ assert.isTrue("nb_tags_extended" in item);
+ assert.isTrue(!("tag1" in item.nb_tags_extended));
+ assert.deepEqual(item.nb_tags_extended.tag2, {
+ label: "tag2",
+ logProb: Math.log(0.86),
+ confident: true,
+ });
+ assert.deepEqual(item.nb_tags_extended.tag3, {
+ label: "tag3",
+ logProb: Math.log(0.9),
+ confident: true,
+ });
+ assert.deepEqual(item.nb_tags_extended.tag5, {
+ label: "tag5",
+ logProb: Math.log(0.9),
+ confident: true,
+ });
+ assert.isTrue("nb_tokens" in item);
+ assert.deepEqual(item.nb_tokens, ["this", "is", "a", "sentence"]);
+ });
+ });
+
+ describe("#conditionallyNmfTag", () => {
+ it("should do nothing if it's not nb tagged", () => {
+ item = instance.conditionallyNmfTag(item, {});
+ assert.equal(item, null);
+ });
+ it("should populate nmf tags for the nb tags", () => {
+ item = instance.naiveBayesTag(item, { fields: ["text"] });
+ item = instance.conditionallyNmfTag(item, {});
+ assert.isTrue("nb_tags" in item);
+ assert.deepEqual(item.nmf_tags, {
+ tag2: {
+ tag21: 0.8,
+ tag22: 0.7,
+ tag23: 0.6,
+ },
+ tag3: {
+ tag31: 0.7,
+ tag32: 0.6,
+ tag33: 0.5,
+ },
+ });
+ assert.deepEqual(item.nmf_tags_parent, {
+ tag21: "tag2",
+ tag22: "tag2",
+ tag23: "tag2",
+ tag31: "tag3",
+ tag32: "tag3",
+ tag33: "tag3",
+ });
+ });
+ it("should not populate nmf tags for things that were not nb tagged", () => {
+ item = instance.naiveBayesTag(item, { fields: ["text"] });
+ item = instance.conditionallyNmfTag(item, {});
+ assert.isTrue("nmf_tags" in item);
+ assert.isTrue(!("tag4" in item.nmf_tags));
+ assert.isTrue("nmf_tags_parent" in item);
+ assert.isTrue(!("tag4" in item.nmf_tags_parent));
+ });
+ });
+
+ describe("#acceptItemByFieldValue", () => {
+ it("should implement ==", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsValue: 2,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsValue: 3,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsField: "two",
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsField: "three",
+ }) === null
+ );
+ });
+ it("should implement !=", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "!=",
+ rhsValue: 2,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "!=",
+ rhsValue: 3,
+ }) !== null
+ );
+ });
+ it("should implement < ", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<",
+ rhsValue: 1,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<",
+ rhsValue: 2,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<",
+ rhsValue: 3,
+ }) !== null
+ );
+ });
+ it("should implement <= ", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<=",
+ rhsValue: 1,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<=",
+ rhsValue: 2,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<=",
+ rhsValue: 3,
+ }) !== null
+ );
+ });
+ it("should implement > ", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">",
+ rhsValue: 1,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">",
+ rhsValue: 2,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">",
+ rhsValue: 3,
+ }) === null
+ );
+ });
+ it("should implement >= ", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">=",
+ rhsValue: 1,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">=",
+ rhsValue: 2,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">=",
+ rhsValue: 3,
+ }) === null
+ );
+ });
+ it("should skip items with missing fields", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "no-left",
+ op: "==",
+ rhsValue: 1,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsField: "no-right",
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, { field: "lhs", op: "==" }) ===
+ null
+ );
+ });
+ it("should skip items with bogus operators", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "bogus",
+ rhsField: "two",
+ }) === null
+ );
+ });
+ });
+
+ describe("#tokenizeUrl", () => {
+ it("should strip the leading www from a url", () => {
+ item = instance.tokenizeUrl(item, { field: "url", dest: "url_toks" });
+ assert.deepEqual(
+ [
+ "wonder",
+ "example",
+ "com",
+ "dir1",
+ "dir2a",
+ "dir2b",
+ "dir3",
+ "4",
+ "key1",
+ "key2",
+ "val2",
+ "key3",
+ "amp",
+ "3",
+ "4",
+ ],
+ item.url_toks
+ );
+ });
+ it("should tokenize the not strip the leading non-wwww token from a url", () => {
+ item = instance.tokenizeUrl(item, { field: "url2", dest: "url_toks" });
+ assert.deepEqual(
+ [
+ "wonder",
+ "example",
+ "com",
+ "dir1",
+ "dir2a",
+ "dir2b",
+ "dir3",
+ "4",
+ "key1",
+ "key2",
+ "val2",
+ "key3",
+ "amp",
+ "3",
+ "4",
+ ],
+ item.url_toks
+ );
+ });
+ it("should error for a missing url", () => {
+ item = instance.tokenizeUrl(item, { field: "missing", dest: "url_toks" });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#getUrlDomain", () => {
+ it("should get only the hostname skipping the www", () => {
+ item = instance.getUrlDomain(item, { field: "url", dest: "url_domain" });
+ assert.isTrue("url_domain" in item);
+ assert.deepEqual("wonder.example.com", item.url_domain);
+ });
+ it("should get only the hostname", () => {
+ item = instance.getUrlDomain(item, { field: "url2", dest: "url_domain" });
+ assert.isTrue("url_domain" in item);
+ assert.deepEqual("wonder.example.com", item.url_domain);
+ });
+ it("should get the hostname and 2 levels of directories", () => {
+ item = instance.getUrlDomain(item, {
+ field: "url",
+ path_length: 2,
+ dest: "url_plus_2",
+ });
+ assert.isTrue("url_plus_2" in item);
+ assert.deepEqual("wonder.example.com/dir1/dir2a-dir2b", item.url_plus_2);
+ });
+ it("should error for a missing url", () => {
+ item = instance.getUrlDomain(item, {
+ field: "missing",
+ dest: "url_domain",
+ });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#tokenizeField", () => {
+ it("should tokenize the field", () => {
+ item = instance.tokenizeField(item, { field: "text", dest: "toks" });
+ assert.isTrue("toks" in item);
+ assert.deepEqual(["this", "is", "a", "sentence"], item.toks);
+ });
+ it("should error for a missing field", () => {
+ item = instance.tokenizeField(item, { field: "missing", dest: "toks" });
+ assert.equal(item, null);
+ });
+ it("should error for a broken config", () => {
+ item = instance.tokenizeField(item, {});
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#_typeOf", () => {
+ it("should know this is a map", () => {
+ assert.equal(instance._typeOf({}), "map");
+ });
+ it("should know this is an array", () => {
+ assert.equal(instance._typeOf([]), "array");
+ });
+ it("should know this is a string", () => {
+ assert.equal(instance._typeOf("blah"), "string");
+ });
+ it("should know this is a boolean", () => {
+ assert.equal(instance._typeOf(true), "boolean");
+ });
+
+ it("should know this is a null", () => {
+ assert.equal(instance._typeOf(null), "null");
+ });
+ });
+
+ describe("#_lookupScalar", () => {
+ it("should return the constant", () => {
+ assert.equal(instance._lookupScalar({}, 1, 0), 1);
+ });
+ it("should return the default", () => {
+ assert.equal(instance._lookupScalar({}, "blah", 42), 42);
+ });
+ it("should return the field's value", () => {
+ assert.equal(instance._lookupScalar({ blah: 11 }, "blah", 42), 11);
+ });
+ });
+
+ describe("#copyValue", () => {
+ it("should copy values", () => {
+ item = instance.copyValue(item, { src: "one", dest: "again" });
+ assert.isTrue("again" in item);
+ assert.equal(item.again, 1);
+ item.one = 100;
+ assert.equal(item.one, 100);
+ assert.equal(item.again, 1);
+ });
+ it("should handle maps corrects", () => {
+ item = instance.copyValue(item, { src: "map", dest: "again" });
+ assert.deepEqual(item.again, { a: 1, b: 2, c: 3 });
+ item.map.c = 100;
+ assert.deepEqual(item.again, { a: 1, b: 2, c: 3 });
+ item.map = 342;
+ assert.deepEqual(item.again, { a: 1, b: 2, c: 3 });
+ });
+ it("should error for a missing field", () => {
+ item = instance.copyValue(item, { src: "missing", dest: "toks" });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#keepTopK", () => {
+ it("should keep the 2 smallest", () => {
+ item = instance.keepTopK(item, { field: "map", k: 2, descending: false });
+ assert.equal(Object.keys(item.map).length, 2);
+ assert.isTrue("a" in item.map);
+ assert.equal(item.map.a, 1);
+ assert.isTrue("b" in item.map);
+ assert.equal(item.map.b, 2);
+ assert.isTrue(!("c" in item.map));
+ });
+ it("should keep the 2 largest", () => {
+ item = instance.keepTopK(item, { field: "map", k: 2, descending: true });
+ assert.equal(Object.keys(item.map).length, 2);
+ assert.isTrue(!("a" in item.map));
+ assert.isTrue("b" in item.map);
+ assert.equal(item.map.b, 2);
+ assert.isTrue("c" in item.map);
+ assert.equal(item.map.c, 3);
+ });
+ it("should still keep the 2 largest", () => {
+ item = instance.keepTopK(item, { field: "map", k: 2 });
+ assert.equal(Object.keys(item.map).length, 2);
+ assert.isTrue(!("a" in item.map));
+ assert.isTrue("b" in item.map);
+ assert.equal(item.map.b, 2);
+ assert.isTrue("c" in item.map);
+ assert.equal(item.map.c, 3);
+ });
+ it("should promote up nested fields", () => {
+ item = instance.keepTopK(item, { field: "tags", k: 2 });
+ assert.equal(Object.keys(item.tags).length, 2);
+ assert.deepEqual(item.tags, { bb: 5, bc: 6 });
+ });
+ it("should error for a missing field", () => {
+ item = instance.keepTopK(item, { field: "missing", k: 3 });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#scalarMultiply", () => {
+ it("should use constants", () => {
+ item = instance.scalarMultiply(item, { field: "map", k: 2 });
+ assert.equal(item.map.a, 2);
+ assert.equal(item.map.b, 4);
+ assert.equal(item.map.c, 6);
+ });
+ it("should use fields", () => {
+ item = instance.scalarMultiply(item, { field: "map", k: "three" });
+ assert.equal(item.map.a, 3);
+ assert.equal(item.map.b, 6);
+ assert.equal(item.map.c, 9);
+ });
+ it("should use default", () => {
+ item = instance.scalarMultiply(item, {
+ field: "map",
+ k: "missing",
+ dfault: 4,
+ });
+ assert.equal(item.map.a, 4);
+ assert.equal(item.map.b, 8);
+ assert.equal(item.map.c, 12);
+ });
+ it("should error for a missing field", () => {
+ item = instance.scalarMultiply(item, { field: "missing", k: 3 });
+ assert.equal(item, null);
+ });
+ it("should multiply numbers", () => {
+ item = instance.scalarMultiply(item, { field: "lhs", k: 2 });
+ assert.equal(item.lhs, 4);
+ });
+ it("should multiply arrays", () => {
+ item = instance.scalarMultiply(item, { field: "arr1", k: 2 });
+ assert.deepEqual(item.arr1, [4, 6, 8]);
+ });
+ it("should should error on strings", () => {
+ item = instance.scalarMultiply(item, { field: "foo", k: 2 });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#elementwiseMultiply", () => {
+ it("should handle maps", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "tags",
+ right: "map2",
+ });
+ assert.deepEqual(item.tags, {
+ a: { aa: 0, ab: 0, ac: 0 },
+ b: { ba: 8, bb: 10, bc: 12 },
+ });
+ });
+ it("should handle arrays of same length", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "arr1",
+ right: "arr2",
+ });
+ assert.deepEqual(item.arr1, [6, 12, 20]);
+ });
+ it("should error for arrays of different lengths", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "arr1",
+ right: "long",
+ });
+ assert.equal(item, null);
+ });
+ it("should error for a missing left", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "missing",
+ right: "arr2",
+ });
+ assert.equal(item, null);
+ });
+ it("should error for a missing right", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "arr1",
+ right: "missing",
+ });
+ assert.equal(item, null);
+ });
+ it("should handle numbers", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "three",
+ right: "two",
+ });
+ assert.equal(item.three, 6);
+ });
+ it("should error for mismatched types", () => {
+ item = instance.elementwiseMultiply(item, { left: "arr1", right: "two" });
+ assert.equal(item, null);
+ });
+ it("should error for strings", () => {
+ item = instance.elementwiseMultiply(item, { left: "foo", right: "bar" });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#vectorMultiply", () => {
+ it("should calculate dot products from maps", () => {
+ item = instance.vectorMultiply(item, {
+ left: "map",
+ right: "map2",
+ dest: "dot",
+ });
+ assert.equal(item.dot, 13);
+ });
+ it("should calculate dot products from arrays", () => {
+ item = instance.vectorMultiply(item, {
+ left: "arr1",
+ right: "arr2",
+ dest: "dot",
+ });
+ assert.equal(item.dot, 38);
+ });
+ it("should error for arrays of different lengths", () => {
+ item = instance.vectorMultiply(item, { left: "arr1", right: "long" });
+ assert.equal(item, null);
+ });
+ it("should error for a missing left", () => {
+ item = instance.vectorMultiply(item, { left: "missing", right: "arr2" });
+ assert.equal(item, null);
+ });
+ it("should error for a missing right", () => {
+ item = instance.vectorMultiply(item, { left: "arr1", right: "missing" });
+ assert.equal(item, null);
+ });
+ it("should error for mismatched types", () => {
+ item = instance.vectorMultiply(item, { left: "arr1", right: "two" });
+ assert.equal(item, null);
+ });
+ it("should error for strings", () => {
+ item = instance.vectorMultiply(item, { left: "foo", right: "bar" });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#scalarAdd", () => {
+ it("should error for a missing field", () => {
+ item = instance.scalarAdd(item, { field: "missing", k: 10 });
+ assert.equal(item, null);
+ });
+ it("should error for strings", () => {
+ item = instance.scalarAdd(item, { field: "foo", k: 10 });
+ assert.equal(item, null);
+ });
+ it("should work for numbers", () => {
+ item = instance.scalarAdd(item, { field: "one", k: 10 });
+ assert.equal(item.one, 11);
+ });
+ it("should add a constant to every cell on a map", () => {
+ item = instance.scalarAdd(item, { field: "map", k: 10 });
+ assert.deepEqual(item.map, { a: 11, b: 12, c: 13 });
+ });
+ it("should add a value from a field to every cell on a map", () => {
+ item = instance.scalarAdd(item, { field: "map", k: "qux" });
+ assert.deepEqual(item.map, { a: 43, b: 44, c: 45 });
+ });
+ it("should add a constant to every cell on an array", () => {
+ item = instance.scalarAdd(item, { field: "arr1", k: 10 });
+ assert.deepEqual(item.arr1, [12, 13, 14]);
+ });
+ });
+
+ describe("#vectorAdd", () => {
+ it("should calculate add vectors from maps", () => {
+ item = instance.vectorAdd(item, { left: "map", right: "map2" });
+ assert.equal(Object.keys(item.map).length, 4);
+ assert.isTrue("a" in item.map);
+ assert.equal(item.map.a, 1);
+ assert.isTrue("b" in item.map);
+ assert.equal(item.map.b, 4);
+ assert.isTrue("c" in item.map);
+ assert.equal(item.map.c, 6);
+ assert.isTrue("d" in item.map);
+ assert.equal(item.map.d, 4);
+ });
+ it("should work for missing left", () => {
+ item = instance.vectorAdd(item, { left: "missing", right: "arr2" });
+ assert.deepEqual(item.missing, [3, 4, 5]);
+ });
+ it("should error for missing right", () => {
+ item = instance.vectorAdd(item, { left: "arr2", right: "missing" });
+ assert.equal(item, null);
+ });
+ it("should error error for strings", () => {
+ item = instance.vectorAdd(item, { left: "foo", right: "bar" });
+ assert.equal(item, null);
+ });
+ it("should error for different types", () => {
+ item = instance.vectorAdd(item, { left: "arr2", right: "map" });
+ assert.equal(item, null);
+ });
+ it("should calculate add vectors from arrays", () => {
+ item = instance.vectorAdd(item, { left: "arr1", right: "arr2" });
+ assert.deepEqual(item.arr1, [5, 7, 9]);
+ });
+ it("should abort on different sized arrays", () => {
+ item = instance.vectorAdd(item, { left: "arr1", right: "long" });
+ assert.equal(item, null);
+ });
+ it("should calculate add vectors from arrays", () => {
+ item = instance.vectorAdd(item, { left: "arr1", right: "arr2" });
+ assert.deepEqual(item.arr1, [5, 7, 9]);
+ });
+ });
+
+ describe("#makeBoolean", () => {
+ it("should error for missing field", () => {
+ item = instance.makeBoolean(item, { field: "missing", threshold: 2 });
+ assert.equal(item, null);
+ });
+ it("should 0/1 a map", () => {
+ item = instance.makeBoolean(item, { field: "map", threshold: 2 });
+ assert.deepEqual(item.map, { a: 0, b: 0, c: 1 });
+ });
+ it("should a map of all 1s", () => {
+ item = instance.makeBoolean(item, { field: "map" });
+ assert.deepEqual(item.map, { a: 1, b: 1, c: 1 });
+ });
+ it("should -1/1 a map", () => {
+ item = instance.makeBoolean(item, {
+ field: "map",
+ threshold: 2,
+ keep_negative: true,
+ });
+ assert.deepEqual(item.map, { a: -1, b: -1, c: 1 });
+ });
+ it("should work an array", () => {
+ item = instance.makeBoolean(item, { field: "arr1", threshold: 3 });
+ assert.deepEqual(item.arr1, [0, 0, 1]);
+ });
+ it("should -1/1 an array", () => {
+ item = instance.makeBoolean(item, {
+ field: "arr1",
+ threshold: 3,
+ keep_negative: true,
+ });
+ assert.deepEqual(item.arr1, [-1, -1, 1]);
+ });
+ it("should 1 a high number", () => {
+ item = instance.makeBoolean(item, { field: "qux", threshold: 3 });
+ assert.equal(item.qux, 1);
+ });
+ it("should 0 a low number", () => {
+ item = instance.makeBoolean(item, { field: "qux", threshold: 70 });
+ assert.equal(item.qux, 0);
+ });
+ it("should -1 a low number", () => {
+ item = instance.makeBoolean(item, {
+ field: "qux",
+ threshold: 83,
+ keep_negative: true,
+ });
+ assert.equal(item.qux, -1);
+ });
+ it("should fail a string", () => {
+ item = instance.makeBoolean(item, { field: "foo", threshold: 3 });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#allowFields", () => {
+ it("should filter the keys out of a map", () => {
+ item = instance.allowFields(item, {
+ fields: ["foo", "missing", "bar"],
+ });
+ assert.deepEqual(item, { foo: "FOO", bar: "BAR" });
+ });
+ });
+
+ describe("#filterByValue", () => {
+ it("should fail on missing field", () => {
+ item = instance.filterByValue(item, { field: "missing", threshold: 2 });
+ assert.equal(item, null);
+ });
+ it("should filter the keys out of a map", () => {
+ item = instance.filterByValue(item, { field: "map", threshold: 2 });
+ assert.deepEqual(item.map, { c: 3 });
+ });
+ });
+
+ describe("#l2Normalize", () => {
+ it("should fail on missing field", () => {
+ item = instance.l2Normalize(item, { field: "missing" });
+ assert.equal(item, null);
+ });
+ it("should L2 normalize an array", () => {
+ item = instance.l2Normalize(item, { field: "arr1" });
+ assert.deepEqual(
+ item.arr1,
+ [0.3713906763541037, 0.5570860145311556, 0.7427813527082074]
+ );
+ });
+ it("should L2 normalize a map", () => {
+ item = instance.l2Normalize(item, { field: "map" });
+ assert.deepEqual(item.map, {
+ a: 0.2672612419124244,
+ b: 0.5345224838248488,
+ c: 0.8017837257372732,
+ });
+ });
+ it("should fail a string", () => {
+ item = instance.l2Normalize(item, { field: "foo" });
+ assert.equal(item, null);
+ });
+ it("should not bomb on a zero vector", () => {
+ item = instance.l2Normalize(item, { field: "zero" });
+ assert.deepEqual(item.zero, { a: 0, b: 0 });
+ item = instance.l2Normalize(item, { field: "zaro" });
+ assert.deepEqual(item.zaro, [0, 0]);
+ });
+ });
+
+ describe("#probNormalize", () => {
+ it("should fail on missing field", () => {
+ item = instance.probNormalize(item, { field: "missing" });
+ assert.equal(item, null);
+ });
+ it("should normalize an array to sum to 1", () => {
+ item = instance.probNormalize(item, { field: "arr1" });
+ assert.deepEqual(
+ item.arr1,
+ [0.2222222222222222, 0.3333333333333333, 0.4444444444444444]
+ );
+ });
+ it("should normalize a map to sum to 1", () => {
+ item = instance.probNormalize(item, { field: "map" });
+ assert.equal(Object.keys(item.map).length, 3);
+ assert.isTrue("a" in item.map);
+ assert.isTrue(Math.abs(item.map.a - 0.16667) <= EPSILON);
+ assert.isTrue("b" in item.map);
+ assert.isTrue(Math.abs(item.map.b - 0.33333) <= EPSILON);
+ assert.isTrue("c" in item.map);
+ assert.isTrue(Math.abs(item.map.c - 0.5) <= EPSILON);
+ });
+ it("should fail a string", () => {
+ item = instance.probNormalize(item, { field: "foo" });
+ assert.equal(item, null);
+ });
+ it("should not bomb on a zero vector", () => {
+ item = instance.probNormalize(item, { field: "zero" });
+ assert.deepEqual(item.zero, { a: 0, b: 0 });
+ item = instance.probNormalize(item, { field: "zaro" });
+ assert.deepEqual(item.zaro, [0, 0]);
+ });
+ });
+
+ describe("#scalarMultiplyTag", () => {
+ it("should fail on missing field", () => {
+ item = instance.scalarMultiplyTag(item, { field: "missing", k: 3 });
+ assert.equal(item, null);
+ });
+ it("should scalar multiply a nested map", () => {
+ item = instance.scalarMultiplyTag(item, {
+ field: "tags",
+ k: 3,
+ log_scale: false,
+ });
+ assert.isTrue(Math.abs(item.tags.a.aa - 0.3) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.a.ab - 0.6) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.a.ac - 0.9) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.ba - 12) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.bb - 15) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.bc - 18) <= EPSILON);
+ });
+ it("should scalar multiply a nested map with logrithms", () => {
+ item = instance.scalarMultiplyTag(item, {
+ field: "tags",
+ k: 3,
+ log_scale: true,
+ });
+ assert.isTrue(
+ Math.abs(item.tags.a.aa - Math.log(0.1 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.a.ab - Math.log(0.2 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.a.ac - Math.log(0.3 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.b.ba - Math.log(4.0 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.b.bb - Math.log(5.0 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.b.bc - Math.log(6.0 + 0.000001) * 3) <= EPSILON
+ );
+ });
+ it("should fail a string", () => {
+ item = instance.scalarMultiplyTag(item, { field: "foo", k: 3 });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#setDefault", () => {
+ it("should store a missing value", () => {
+ item = instance.setDefault(item, { field: "missing", value: 1111 });
+ assert.equal(item.missing, 1111);
+ });
+ it("should not overwrite an existing value", () => {
+ item = instance.setDefault(item, { field: "lhs", value: 1111 });
+ assert.equal(item.lhs, 2);
+ });
+ it("should store a complex value", () => {
+ item = instance.setDefault(item, { field: "missing", value: { a: 1 } });
+ assert.deepEqual(item.missing, { a: 1 });
+ });
+ });
+
+ describe("#lookupValue", () => {
+ it("should promote a value", () => {
+ item = instance.lookupValue(item, {
+ haystack: "map",
+ needle: "c",
+ dest: "ccc",
+ });
+ assert.equal(item.ccc, 3);
+ });
+ it("should handle a missing haystack", () => {
+ item = instance.lookupValue(item, {
+ haystack: "missing",
+ needle: "c",
+ dest: "ccc",
+ });
+ assert.isTrue(!("ccc" in item));
+ });
+ it("should handle a missing needle", () => {
+ item = instance.lookupValue(item, {
+ haystack: "map",
+ needle: "missing",
+ dest: "ccc",
+ });
+ assert.isTrue(!("ccc" in item));
+ });
+ });
+
+ describe("#copyToMap", () => {
+ it("should copy a value to a map", () => {
+ item = instance.copyToMap(item, {
+ src: "qux",
+ dest_map: "map",
+ dest_key: "zzz",
+ });
+ assert.isTrue("zzz" in item.map);
+ assert.equal(item.map.zzz, item.qux);
+ });
+ it("should create a new map to hold the key", () => {
+ item = instance.copyToMap(item, {
+ src: "qux",
+ dest_map: "missing",
+ dest_key: "zzz",
+ });
+ assert.equal(Object.keys(item.missing).length, 1);
+ assert.equal(item.missing.zzz, item.qux);
+ });
+ it("should not create an empty map if the src is missing", () => {
+ item = instance.copyToMap(item, {
+ src: "missing",
+ dest_map: "no_map",
+ dest_key: "zzz",
+ });
+ assert.isTrue(!("no_map" in item));
+ });
+ });
+
+ describe("#applySoftmaxTags", () => {
+ it("should error on missing field", () => {
+ item = instance.applySoftmaxTags(item, { field: "missing" });
+ assert.equal(item, null);
+ });
+ it("should error on nonmaps", () => {
+ item = instance.applySoftmaxTags(item, { field: "arr1" });
+ assert.equal(item, null);
+ });
+ it("should error on unnested maps", () => {
+ item = instance.applySoftmaxTags(item, { field: "map" });
+ assert.equal(item, null);
+ });
+ it("should error on wrong nested maps", () => {
+ item = instance.applySoftmaxTags(item, { field: "bogus" });
+ assert.equal(item, null);
+ });
+ it("should apply softmax across the subtags", () => {
+ item = instance.applySoftmaxTags(item, { field: "tags" });
+ assert.isTrue("a" in item.tags);
+ assert.isTrue("aa" in item.tags.a);
+ assert.isTrue("ab" in item.tags.a);
+ assert.isTrue("ac" in item.tags.a);
+ assert.isTrue(Math.abs(item.tags.a.aa - 0.30061) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.a.ab - 0.33222) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.a.ac - 0.36717) <= EPSILON);
+
+ assert.isTrue("b" in item.tags);
+ assert.isTrue("ba" in item.tags.b);
+ assert.isTrue("bb" in item.tags.b);
+ assert.isTrue("bc" in item.tags.b);
+ assert.isTrue(Math.abs(item.tags.b.ba - 0.09003) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.bb - 0.24473) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.bc - 0.66524) <= EPSILON);
+ });
+ });
+
+ describe("#combinerAdd", () => {
+ it("should do nothing when right field is missing", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "missing" });
+ assert.deepEqual(combined, item);
+ });
+ it("should handle missing left maps", () => {
+ let right = makeItem();
+ right.missingmap = { a: 5, b: -1, c: 3 };
+ let combined = instance.combinerAdd(item, right, { field: "missingmap" });
+ assert.deepEqual(combined.missingmap, { a: 5, b: -1, c: 3 });
+ });
+ it("should add equal sized maps", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 2, b: 4, c: 6 });
+ });
+ it("should add long map to short map", () => {
+ let right = makeItem();
+ right.map.d = 999;
+ let combined = instance.combinerAdd(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 2, b: 4, c: 6, d: 999 });
+ });
+ it("should add short map to long map", () => {
+ let right = makeItem();
+ item.map.d = 999;
+ let combined = instance.combinerAdd(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 2, b: 4, c: 6, d: 999 });
+ });
+ it("should add equal sized arrays", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [4, 6, 8]);
+ });
+ it("should handle missing left arrays", () => {
+ let right = makeItem();
+ right.missingarray = [5, 1, 4];
+ let combined = instance.combinerAdd(item, right, {
+ field: "missingarray",
+ });
+ assert.deepEqual(combined.missingarray, [5, 1, 4]);
+ });
+ it("should add long array to short array", () => {
+ let right = makeItem();
+ right.arr1 = [2, 3, 4, 12];
+ let combined = instance.combinerAdd(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [4, 6, 8, 12]);
+ });
+ it("should add short array to long array", () => {
+ let right = makeItem();
+ item.arr1 = [2, 3, 4, 12];
+ let combined = instance.combinerAdd(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [4, 6, 8, 12]);
+ });
+ it("should handle missing left number", () => {
+ let right = makeItem();
+ right.missingnumber = 999;
+ let combined = instance.combinerAdd(item, right, {
+ field: "missingnumber",
+ });
+ assert.deepEqual(combined.missingnumber, 999);
+ });
+ it("should add numbers", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "lhs" });
+ assert.equal(combined.lhs, 4);
+ });
+ it("should error on missing left, and right is a string", () => {
+ let right = makeItem();
+ right.error = "error";
+ let combined = instance.combinerAdd(item, right, { field: "error" });
+ assert.equal(combined, null);
+ });
+ it("should error on left string", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "foo" });
+ assert.equal(combined, null);
+ });
+ it("should error on mismatch types", () => {
+ let right = makeItem();
+ right.lhs = [1, 2, 3];
+ let combined = instance.combinerAdd(item, right, { field: "lhs" });
+ assert.equal(combined, null);
+ });
+ });
+
+ describe("#combinerMax", () => {
+ it("should do nothing when right field is missing", () => {
+ let right = makeItem();
+ let combined = instance.combinerMax(item, right, { field: "missing" });
+ assert.deepEqual(combined, item);
+ });
+ it("should handle missing left maps", () => {
+ let right = makeItem();
+ right.missingmap = { a: 5, b: -1, c: 3 };
+ let combined = instance.combinerMax(item, right, { field: "missingmap" });
+ assert.deepEqual(combined.missingmap, { a: 5, b: -1, c: 3 });
+ });
+ it("should handle equal sized maps", () => {
+ let right = makeItem();
+ right.map = { a: 5, b: -1, c: 3 };
+ let combined = instance.combinerMax(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 5, b: 2, c: 3 });
+ });
+ it("should handle short map to long map", () => {
+ let right = makeItem();
+ right.map = { a: 5, b: -1, c: 3, d: 999 };
+ let combined = instance.combinerMax(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 5, b: 2, c: 3, d: 999 });
+ });
+ it("should handle long map to short map", () => {
+ let right = makeItem();
+ right.map = { a: 5, b: -1, c: 3 };
+ item.map.d = 999;
+ let combined = instance.combinerMax(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 5, b: 2, c: 3, d: 999 });
+ });
+ it("should handle equal sized arrays", () => {
+ let right = makeItem();
+ right.arr1 = [5, 1, 4];
+ let combined = instance.combinerMax(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [5, 3, 4]);
+ });
+ it("should handle missing left arrays", () => {
+ let right = makeItem();
+ right.missingarray = [5, 1, 4];
+ let combined = instance.combinerMax(item, right, {
+ field: "missingarray",
+ });
+ assert.deepEqual(combined.missingarray, [5, 1, 4]);
+ });
+ it("should handle short array to long array", () => {
+ let right = makeItem();
+ right.arr1 = [5, 1, 4, 7];
+ let combined = instance.combinerMax(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [5, 3, 4, 7]);
+ });
+ it("should handle long array to short array", () => {
+ let right = makeItem();
+ right.arr1 = [5, 1, 4];
+ item.arr1.push(7);
+ let combined = instance.combinerMax(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [5, 3, 4, 7]);
+ });
+ it("should handle missing left number", () => {
+ let right = makeItem();
+ right.missingnumber = 999;
+ let combined = instance.combinerMax(item, right, {
+ field: "missingnumber",
+ });
+ assert.deepEqual(combined.missingnumber, 999);
+ });
+ it("should handle big number", () => {
+ let right = makeItem();
+ right.lhs = 99;
+ let combined = instance.combinerMax(item, right, { field: "lhs" });
+ assert.equal(combined.lhs, 99);
+ });
+ it("should handle small number", () => {
+ let right = makeItem();
+ item.lhs = 99;
+ let combined = instance.combinerMax(item, right, { field: "lhs" });
+ assert.equal(combined.lhs, 99);
+ });
+ it("should error on missing left, and right is a string", () => {
+ let right = makeItem();
+ right.error = "error";
+ let combined = instance.combinerMax(item, right, { field: "error" });
+ assert.equal(combined, null);
+ });
+ it("should error on left string", () => {
+ let right = makeItem();
+ let combined = instance.combinerMax(item, right, { field: "foo" });
+ assert.equal(combined, null);
+ });
+ it("should error on mismatch types", () => {
+ let right = makeItem();
+ right.lhs = [1, 2, 3];
+ let combined = instance.combinerMax(item, right, { field: "lhs" });
+ assert.equal(combined, null);
+ });
+ });
+
+ describe("#combinerCollectValues", () => {
+ it("should error on bogus operation", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "missing",
+ });
+ assert.equal(combined, null);
+ });
+ it("should sum when missing left", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "sum",
+ });
+ assert.deepEqual(combined.combined_map, {
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+ it("should sum when missing right", () => {
+ let right = makeItem();
+ item.combined_map = { fake: 42 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "sum",
+ });
+ assert.deepEqual(combined.combined_map, { fake: 42 });
+ });
+ it("should sum when both", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 41 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "sum",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 82,
+ });
+ });
+
+ it("should max when missing left", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "max",
+ });
+ assert.deepEqual(combined.combined_map, {
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+ it("should max when missing right", () => {
+ let right = makeItem();
+ item.combined_map = { fake: 42 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "max",
+ });
+ assert.deepEqual(combined.combined_map, { fake: 42 });
+ });
+ it("should max when both (right)", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 99;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 41 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "max",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 99,
+ });
+ });
+ it("should max when both (left)", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = -99;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 41 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "max",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+
+ it("should overwrite when missing left", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "overwrite",
+ });
+ assert.deepEqual(combined.combined_map, {
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+ it("should overwrite when missing right", () => {
+ let right = makeItem();
+ item.combined_map = { fake: 42 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "overwrite",
+ });
+ assert.deepEqual(combined.combined_map, { fake: 42 });
+ });
+ it("should overwrite when both", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 77 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "overwrite",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+
+ it("should count when missing left", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "count",
+ });
+ assert.deepEqual(combined.combined_map, {
+ "maseratiusa.com/maserati": 1,
+ });
+ });
+ it("should count when missing right", () => {
+ let right = makeItem();
+ item.combined_map = { fake: 42 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "count",
+ });
+ assert.deepEqual(combined.combined_map, { fake: 42 });
+ });
+ it("should count when both", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 1 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "count",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 2,
+ });
+ });
+ });
+
+ describe("#executeRecipe", () => {
+ it("should handle working steps", () => {
+ let final = instance.executeRecipe({}, [
+ { function: "set_default", field: "foo", value: 1 },
+ { function: "set_default", field: "bar", value: 10 },
+ ]);
+ assert.equal(final.foo, 1);
+ assert.equal(final.bar, 10);
+ });
+ it("should handle unknown steps", () => {
+ let final = instance.executeRecipe({}, [
+ { function: "set_default", field: "foo", value: 1 },
+ { function: "missing" },
+ { function: "set_default", field: "bar", value: 10 },
+ ]);
+ assert.equal(final, null);
+ });
+ it("should handle erroring steps", () => {
+ let final = instance.executeRecipe({}, [
+ { function: "set_default", field: "foo", value: 1 },
+ {
+ function: "accept_item_by_field_value",
+ field: "missing",
+ op: "invalid",
+ rhsField: "moot",
+ rhsValue: "m00t",
+ },
+ { function: "set_default", field: "bar", value: 10 },
+ ]);
+ assert.equal(final, null);
+ });
+ });
+
+ describe("#executeCombinerRecipe", () => {
+ it("should handle working steps", () => {
+ let final = instance.executeCombinerRecipe(
+ { foo: 1, bar: 10 },
+ { foo: 1, bar: 10 },
+ [
+ { function: "combiner_add", field: "foo" },
+ { function: "combiner_add", field: "bar" },
+ ]
+ );
+ assert.equal(final.foo, 2);
+ assert.equal(final.bar, 20);
+ });
+ it("should handle unknown steps", () => {
+ let final = instance.executeCombinerRecipe(
+ { foo: 1, bar: 10 },
+ { foo: 1, bar: 10 },
+ [
+ { function: "combiner_add", field: "foo" },
+ { function: "missing" },
+ { function: "combiner_add", field: "bar" },
+ ]
+ );
+ assert.equal(final, null);
+ });
+ it("should handle erroring steps", () => {
+ let final = instance.executeCombinerRecipe(
+ { foo: 1, bar: 10, baz: 0 },
+ { foo: 1, bar: 10, baz: "hundred" },
+ [
+ { function: "combiner_add", field: "foo" },
+ { function: "combiner_add", field: "baz" },
+ { function: "combiner_add", field: "bar" },
+ ]
+ );
+ assert.equal(final, null);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js
new file mode 100644
index 0000000000..8503c2903b
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js
@@ -0,0 +1,134 @@
+import {
+ tokenize,
+ toksToTfIdfVector,
+} from "lib/PersonalityProvider/Tokenize.jsm";
+
+const EPSILON = 0.00001;
+
+describe("TF-IDF Term Vectorizer", () => {
+ describe("#tokenize", () => {
+ let testCases = [
+ { input: "HELLO there", expected: ["hello", "there"] },
+ { input: "blah,,,blah,blah", expected: ["blah", "blah", "blah"] },
+ {
+ input: "Call Jenny: 967-5309",
+ expected: ["call", "jenny", "967", "5309"],
+ },
+ {
+ input: "Yo(what)[[hello]]{{jim}}}bob{1:2:1+2=$3",
+ expected: [
+ "yo",
+ "what",
+ "hello",
+ "jim",
+ "bob",
+ "1",
+ "2",
+ "1",
+ "2",
+ "3",
+ ],
+ },
+ { input: "čÄfė 80's", expected: ["čäfė", "80", "s"] },
+ { input: "我知道很多东西。", expected: ["我知道很多东西"] },
+ ];
+ let checkTokenization = tc => {
+ it(`${tc.input} should tokenize to ${tc.expected}`, () => {
+ assert.deepEqual(tc.expected, tokenize(tc.input));
+ });
+ };
+
+ for (let i = 0; i < testCases.length; i++) {
+ checkTokenization(testCases[i]);
+ }
+ });
+
+ describe("#tfidf", () => {
+ let vocab_idfs = {
+ deal: [221, 5.5058519847862275],
+ easy: [269, 5.5058519847862275],
+ tanks: [867, 5.601162164590552],
+ sites: [792, 5.957837108529285],
+ care: [153, 5.957837108529285],
+ needs: [596, 5.824305715904762],
+ finally: [334, 5.706522680248379],
+ };
+ let testCases = [
+ {
+ input: "Finally! Easy care for your tanks!",
+ expected: {
+ finally: [334, 0.5009816295853761],
+ easy: [269, 0.48336453811728713],
+ care: [153, 0.5230447876368227],
+ tanks: [867, 0.49173191907236774],
+ },
+ },
+ {
+ input: "Easy easy EASY",
+ expected: { easy: [269, 1.0] },
+ },
+ {
+ input: "Easy easy care",
+ expected: {
+ easy: [269, 0.8795205218806832],
+ care: [153, 0.4758609582543317],
+ },
+ },
+ {
+ input: "easy care",
+ expected: {
+ easy: [269, 0.6786999710383944],
+ care: [153, 0.7344156515982504],
+ },
+ },
+ {
+ input: "这个空间故意留空。",
+ expected: {
+ /* This space is left intentionally blank. */
+ },
+ },
+ ];
+ let checkTokenGeneration = tc => {
+ describe(`${tc.input} should have only vocabulary tokens`, () => {
+ let actual = toksToTfIdfVector(tokenize(tc.input), vocab_idfs);
+
+ it(`${tc.input} should generate exactly ${Object.keys(
+ tc.expected
+ )}`, () => {
+ let seen = {};
+ Object.keys(actual).forEach(actualTok => {
+ assert.isTrue(actualTok in tc.expected);
+ seen[actualTok] = true;
+ });
+ Object.keys(tc.expected).forEach(expectedTok => {
+ assert.isTrue(expectedTok in seen);
+ });
+ });
+
+ it(`${tc.input} should have the correct token ids`, () => {
+ Object.keys(actual).forEach(actualTok => {
+ assert.equal(tc.expected[actualTok][0], actual[actualTok][0]);
+ });
+ });
+ });
+ };
+
+ let checkTfIdfVector = tc => {
+ let actual = toksToTfIdfVector(tokenize(tc.input), vocab_idfs);
+ it(`${tc.input} should have the correct tf-idf`, () => {
+ Object.keys(actual).forEach(actualTok => {
+ let delta = Math.abs(
+ tc.expected[actualTok][1] - actual[actualTok][1]
+ );
+ assert.isTrue(delta <= EPSILON);
+ });
+ });
+ };
+
+ // run the tests
+ for (let i = 0; i < testCases.length; i++) {
+ checkTokenGeneration(testCases[i]);
+ checkTfIdfVector(testCases[i]);
+ }
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PlacesFeed.test.js b/browser/components/newtab/test/unit/lib/PlacesFeed.test.js
new file mode 100644
index 0000000000..20210ab7b1
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PlacesFeed.test.js
@@ -0,0 +1,1245 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import injector from "inject!lib/PlacesFeed.jsm";
+
+const FAKE_BOOKMARK = {
+ bookmarkGuid: "xi31",
+ bookmarkTitle: "Foo",
+ dateAdded: 123214232,
+ url: "foo.com",
+};
+const TYPE_BOOKMARK = 0; // This is fake, for testing
+const SOURCES = {
+ DEFAULT: 0,
+ SYNC: 1,
+ IMPORT: 2,
+ RESTORE: 5,
+ RESTORE_ON_STARTUP: 6,
+};
+
+const BLOCKED_EVENT = "newtab-linkBlocked"; // The event dispatched in NewTabUtils when a link is blocked;
+
+const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors";
+const POCKET_SITE_PREF = "extensions.pocket.site";
+
+describe("PlacesFeed", () => {
+ let PlacesFeed;
+ let PlacesObserver;
+ let globals;
+ let sandbox;
+ let feed;
+ let shortURLStub;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ globals.set("NewTabUtils", {
+ activityStreamProvider: { getBookmark() {} },
+ activityStreamLinks: {
+ addBookmark: sandbox.spy(),
+ deleteBookmark: sandbox.spy(),
+ deleteHistoryEntry: sandbox.spy(),
+ blockURL: sandbox.spy(),
+ addPocketEntry: sandbox.spy(() => Promise.resolve()),
+ deletePocketEntry: sandbox.spy(() => Promise.resolve()),
+ archivePocketEntry: sandbox.spy(() => Promise.resolve()),
+ },
+ });
+ globals.set("pktApi", {
+ isUserLoggedIn: sandbox.spy(),
+ });
+ globals.set("ExperimentAPI", {
+ getExperiment: sandbox.spy(),
+ });
+ globals.set("NimbusFeatures", {
+ pocketNewtab: {
+ getVariable: sandbox.spy(),
+ },
+ });
+ globals.set("PartnerLinkAttribution", {
+ makeRequest: sandbox.spy(),
+ });
+ sandbox
+ .stub(global.PlacesUtils.bookmarks, "TYPE_BOOKMARK")
+ .value(TYPE_BOOKMARK);
+ sandbox.stub(global.PlacesUtils.bookmarks, "SOURCES").value(SOURCES);
+ sandbox.spy(global.PlacesUtils.history, "addObserver");
+ sandbox.spy(global.PlacesUtils.history, "removeObserver");
+ sandbox.spy(global.PlacesUtils.observers, "addListener");
+ sandbox.spy(global.PlacesUtils.observers, "removeListener");
+ sandbox.spy(global.Services.obs, "addObserver");
+ sandbox.spy(global.Services.obs, "removeObserver");
+ sandbox.spy(global.console, "error");
+ shortURLStub = sandbox
+ .stub()
+ .callsFake(site =>
+ site.url.replace(/(.com|.ca)/, "").replace("https://", "")
+ );
+
+ global.Services.io.newURI = spec => ({
+ mutate: () => ({
+ setRef: ref => ({
+ finalize: () => ({
+ ref,
+ spec,
+ }),
+ }),
+ }),
+ spec,
+ scheme: "https",
+ });
+
+ global.Cc["@mozilla.org/timer;1"] = {
+ createInstance() {
+ return {
+ initWithCallback: sinon.stub().callsFake(callback => callback()),
+ cancel: sinon.spy(),
+ };
+ },
+ };
+ ({ PlacesFeed } = injector({
+ "lib/ShortURL.jsm": { shortURL: shortURLStub },
+ }));
+ PlacesObserver = PlacesFeed.PlacesObserver;
+ feed = new PlacesFeed();
+ feed.store = { dispatch: sinon.spy() };
+ globals.set("AboutNewTab", {
+ activityStream: { store: { feeds: { get() {} } } },
+ });
+ });
+ afterEach(() => {
+ globals.restore();
+ sandbox.restore();
+ });
+
+ it("should have a PlacesObserver that dispatches to the store", () => {
+ assert.instanceOf(feed.placesObserver, PlacesObserver);
+ const action = { type: "FOO" };
+
+ feed.placesObserver.dispatch(action);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.equal(feed.store.dispatch.firstCall.args[0].type, action.type);
+ });
+
+ describe("#addToBlockedTopSitesSponsors", () => {
+ let spy;
+ beforeEach(() => {
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF)
+ .returns(`["foo","bar"]`);
+ spy = sandbox.spy(global.Services.prefs, "setStringPref");
+ });
+
+ it("should add the blocked sponsors to the blocklist", () => {
+ feed.addToBlockedTopSitesSponsors([
+ { url: "test.com" },
+ { url: "test1.com" },
+ ]);
+
+ assert.calledOnce(spy);
+ const [, sponsors] = spy.firstCall.args;
+ assert.deepEqual(
+ new Set(["foo", "bar", "test", "test1"]),
+ new Set(JSON.parse(sponsors))
+ );
+ });
+
+ it("should not add duplicate sponsors to the blocklist", () => {
+ feed.addToBlockedTopSitesSponsors([
+ { url: "foo.com" },
+ { url: "bar.com" },
+ { url: "test.com" },
+ ]);
+
+ assert.calledOnce(spy);
+ const [, sponsors] = spy.firstCall.args;
+ assert.deepEqual(
+ new Set(["foo", "bar", "test"]),
+ new Set(JSON.parse(sponsors))
+ );
+ });
+ });
+
+ describe("#onAction", () => {
+ it("should add bookmark, history, places, blocked observers on INIT", () => {
+ feed.onAction({ type: at.INIT });
+
+ assert.calledWith(
+ global.PlacesUtils.observers.addListener,
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "history-cleared",
+ "page-removed",
+ ],
+ feed.placesObserver.handlePlacesEvent
+ );
+ assert.calledWith(global.Services.obs.addObserver, feed, BLOCKED_EVENT);
+ });
+ it("should remove bookmark, history, places, blocked observers, and timers on UNINIT", () => {
+ feed.placesChangedTimer =
+ global.Cc["@mozilla.org/timer;1"].createInstance();
+ let spy = feed.placesChangedTimer.cancel;
+ feed.onAction({ type: at.UNINIT });
+
+ assert.calledWith(
+ global.PlacesUtils.observers.removeListener,
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "history-cleared",
+ "page-removed",
+ ],
+ feed.placesObserver.handlePlacesEvent
+ );
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ feed,
+ BLOCKED_EVENT
+ );
+ assert.equal(feed.placesChangedTimer, null);
+ assert.calledOnce(spy);
+ });
+ it("should block a url on BLOCK_URL", () => {
+ feed.onAction({
+ type: at.BLOCK_URL,
+ data: [{ url: "apple.com", pocket_id: 1234 }],
+ });
+ assert.calledWith(global.NewTabUtils.activityStreamLinks.blockURL, {
+ url: "apple.com",
+ pocket_id: 1234,
+ });
+ });
+ it("should update the blocked top sites sponsors", () => {
+ sandbox.stub(feed, "addToBlockedTopSitesSponsors");
+ feed.onAction({
+ type: at.BLOCK_URL,
+ data: [{ url: "foo.com", pocket_id: 1234, isSponsoredTopSite: 1 }],
+ });
+ assert.calledWith(feed.addToBlockedTopSitesSponsors, [
+ { url: "foo.com" },
+ ]);
+ });
+ it("should bookmark a url on BOOKMARK_URL", () => {
+ const data = { url: "pear.com", title: "A pear" };
+ const _target = { browser: { ownerGlobal() {} } };
+ feed.onAction({ type: at.BOOKMARK_URL, data, _target });
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.addBookmark,
+ data,
+ _target.browser.ownerGlobal
+ );
+ });
+ it("should delete a bookmark on DELETE_BOOKMARK_BY_ID", () => {
+ feed.onAction({ type: at.DELETE_BOOKMARK_BY_ID, data: "g123kd" });
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.deleteBookmark,
+ "g123kd"
+ );
+ });
+ it("should delete a history entry on DELETE_HISTORY_URL", () => {
+ feed.onAction({
+ type: at.DELETE_HISTORY_URL,
+ data: { url: "guava.com", forceBlock: null },
+ });
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.deleteHistoryEntry,
+ "guava.com"
+ );
+ assert.notCalled(global.NewTabUtils.activityStreamLinks.blockURL);
+ });
+ it("should delete a history entry on DELETE_HISTORY_URL and force a site to be blocked if specified", () => {
+ feed.onAction({
+ type: at.DELETE_HISTORY_URL,
+ data: { url: "guava.com", forceBlock: "g123kd" },
+ });
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.deleteHistoryEntry,
+ "guava.com"
+ );
+ assert.calledWith(global.NewTabUtils.activityStreamLinks.blockURL, {
+ url: "guava.com",
+ pocket_id: undefined,
+ });
+ });
+ it("should call openTrustedLinkIn with the correct url, where and params on OPEN_NEW_WINDOW", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openWindowAction = {
+ type: at.OPEN_NEW_WINDOW,
+ data: { url: "https://foo.com" },
+ _target: { browser: { ownerGlobal: { openTrustedLinkIn } } },
+ };
+
+ feed.onAction(openWindowAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where, params] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url, "https://foo.com");
+ assert.equal(where, "window");
+ assert.propertyVal(params, "private", false);
+ assert.propertyVal(params, "forceForeground", false);
+ });
+ it("should call openTrustedLinkIn with the correct url, where, params and privacy args on OPEN_PRIVATE_WINDOW", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openWindowAction = {
+ type: at.OPEN_PRIVATE_WINDOW,
+ data: { url: "https://foo.com" },
+ _target: { browser: { ownerGlobal: { openTrustedLinkIn } } },
+ };
+
+ feed.onAction(openWindowAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where, params] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url, "https://foo.com");
+ assert.equal(where, "window");
+ assert.propertyVal(params, "private", true);
+ assert.propertyVal(params, "forceForeground", false);
+ });
+ it("should call openTrustedLinkIn with the correct url, where and params on OPEN_LINK", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openLinkAction = {
+ type: at.OPEN_LINK,
+ data: { url: "https://foo.com" },
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where, params] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url, "https://foo.com");
+ assert.equal(where, "current");
+ assert.propertyVal(params, "private", false);
+ assert.propertyVal(params, "forceForeground", false);
+ });
+ it("should open link with referrer on OPEN_LINK", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openLinkAction = {
+ type: at.OPEN_LINK,
+ data: { url: "https://foo.com", referrer: "https://foo.com/ref" },
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "tab" },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+
+ const [, , params] = openTrustedLinkIn.firstCall.args;
+ assert.nestedPropertyVal(params, "referrerInfo.referrerPolicy", 5);
+ assert.nestedPropertyVal(
+ params,
+ "referrerInfo.originalReferrer.spec",
+ "https://foo.com/ref"
+ );
+ });
+ it("should mark link with typed bonus as typed before opening OPEN_LINK", () => {
+ const callOrder = [];
+ sinon
+ .stub(global.PlacesUtils.history, "markPageAsTyped")
+ .callsFake(() => {
+ callOrder.push("markPageAsTyped");
+ });
+ const openTrustedLinkIn = sinon.stub().callsFake(() => {
+ callOrder.push("openTrustedLinkIn");
+ });
+ const openLinkAction = {
+ type: at.OPEN_LINK,
+ data: {
+ typedBonus: true,
+ url: "https://foo.com",
+ },
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "tab" },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+
+ assert.sameOrderedMembers(callOrder, [
+ "markPageAsTyped",
+ "openTrustedLinkIn",
+ ]);
+ });
+ it("should open the pocket link if it's a pocket story on OPEN_LINK", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openLinkAction = {
+ type: at.OPEN_LINK,
+ data: {
+ url: "https://foo.com",
+ open_url: "getpocket.com/foo",
+ type: "pocket",
+ },
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where, params] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url, "getpocket.com/foo");
+ assert.equal(where, "current");
+ assert.propertyVal(params, "private", false);
+ });
+ it("should not open link if not http", () => {
+ const openTrustedLinkIn = sinon.stub();
+ global.Services.io.newURI = spec => ({
+ mutate: () => ({
+ setRef: ref => ({
+ finalize: () => ({
+ ref,
+ spec,
+ }),
+ }),
+ }),
+ spec,
+ scheme: "file",
+ });
+ const openLinkAction = {
+ type: at.OPEN_LINK,
+ data: { url: "file:///foo.com" },
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+ const [e] = global.console.error.firstCall.args;
+ assert.equal(
+ e.message,
+ "Can't open link using file protocol from the new tab page."
+ );
+ });
+ it("should call fillSearchTopSiteTerm on FILL_SEARCH_TERM", () => {
+ sinon.stub(feed, "fillSearchTopSiteTerm");
+
+ feed.onAction({ type: at.FILL_SEARCH_TERM });
+
+ assert.calledOnce(feed.fillSearchTopSiteTerm);
+ });
+ it("should call openTrustedLinkIn with the correct SUMO url on ABOUT_SPONSORED_TOP_SITES", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openLinkAction = {
+ type: at.ABOUT_SPONSORED_TOP_SITES,
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url.endsWith("sponsor-privacy"), true);
+ assert.equal(where, "tab");
+ });
+ it("should set the URL bar value to the label value", async () => {
+ const locationBar = { search: sandbox.stub() };
+ const action = {
+ type: at.FILL_SEARCH_TERM,
+ data: { label: "@Foo" },
+ _target: { browser: { ownerGlobal: { gURLBar: locationBar } } },
+ };
+
+ await feed.fillSearchTopSiteTerm(action);
+
+ assert.calledOnce(locationBar.search);
+ assert.calledWithExactly(locationBar.search, "@Foo", {
+ searchEngine: null,
+ searchModeEntry: "topsites_newtab",
+ });
+ });
+ it("should call saveToPocket on SAVE_TO_POCKET", () => {
+ const action = {
+ type: at.SAVE_TO_POCKET,
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ sinon.stub(feed, "saveToPocket");
+ feed.onAction(action);
+ assert.calledWithExactly(
+ feed.saveToPocket,
+ action.data.site,
+ action._target.browser
+ );
+ });
+ it("should openTrustedLinkIn with sendToPocket if not logged in", () => {
+ const openTrustedLinkIn = sinon.stub();
+ global.NimbusFeatures.pocketNewtab.getVariable = sandbox
+ .stub()
+ .returns(true);
+ global.pktApi.isUserLoggedIn = sandbox.stub().returns(false);
+ global.ExperimentAPI.getExperiment = sandbox.stub().returns({
+ slug: "slug",
+ branch: { slug: "branch-slug" },
+ });
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(POCKET_SITE_PREF)
+ .returns("getpocket.com");
+ const action = {
+ type: at.SAVE_TO_POCKET,
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: {
+ browser: {
+ ownerGlobal: {
+ openTrustedLinkIn,
+ },
+ },
+ },
+ };
+ feed.onAction(action);
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where] = openTrustedLinkIn.firstCall.args;
+ assert.equal(
+ url,
+ "https://getpocket.com/signup?utm_source=firefox_newtab_save_button&utm_campaign=slug&utm_content=branch-slug"
+ );
+ assert.equal(where, "tab");
+ });
+ it("should call NewTabUtils.activityStreamLinks.addPocketEntry if we are saving a pocket story", async () => {
+ const action = {
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ await feed.saveToPocket(action.data.site, action._target.browser);
+ assert.calledOnce(global.NewTabUtils.activityStreamLinks.addPocketEntry);
+ assert.calledWithExactly(
+ global.NewTabUtils.activityStreamLinks.addPocketEntry,
+ action.data.site.url,
+ action.data.site.title,
+ action._target.browser
+ );
+ });
+ it("should reject the promise if NewTabUtils.activityStreamLinks.addPocketEntry rejects", async () => {
+ const e = new Error("Error");
+ const action = {
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ global.NewTabUtils.activityStreamLinks.addPocketEntry = sandbox
+ .stub()
+ .rejects(e);
+ await feed.saveToPocket(action.data.site, action._target.browser);
+ assert.calledWith(global.console.error, e);
+ });
+ it("should broadcast to content if we successfully added a link to Pocket", async () => {
+ // test in the form that the API returns data based on: https://getpocket.com/developer/docs/v3/add
+ global.NewTabUtils.activityStreamLinks.addPocketEntry = sandbox
+ .stub()
+ .resolves({ item: { open_url: "pocket.com/itemID", item_id: 1234 } });
+ const action = {
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ await feed.saveToPocket(action.data.site, action._target.browser);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.PLACES_SAVED_TO_POCKET
+ );
+ assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
+ url: "raspberry.com",
+ title: "raspberry",
+ pocket_id: 1234,
+ open_url: "pocket.com/itemID",
+ });
+ });
+ it("should only broadcast if we got some data back from addPocketEntry", async () => {
+ global.NewTabUtils.activityStreamLinks.addPocketEntry = sandbox
+ .stub()
+ .resolves(null);
+ const action = {
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ await feed.saveToPocket(action.data.site, action._target.browser);
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should call deleteFromPocket on DELETE_FROM_POCKET", () => {
+ sandbox.stub(feed, "deleteFromPocket");
+ feed.onAction({
+ type: at.DELETE_FROM_POCKET,
+ data: { pocket_id: 12345 },
+ });
+
+ assert.calledOnce(feed.deleteFromPocket);
+ assert.calledWithExactly(feed.deleteFromPocket, 12345);
+ });
+ it("should catch if deletePocketEntry throws", async () => {
+ const e = new Error("Error");
+ global.NewTabUtils.activityStreamLinks.deletePocketEntry = sandbox
+ .stub()
+ .rejects(e);
+ await feed.deleteFromPocket(12345);
+
+ assert.calledWith(global.console.error, e);
+ });
+ it("should call NewTabUtils.deletePocketEntry and dispatch POCKET_LINK_DELETED_OR_ARCHIVED when deleting from Pocket", async () => {
+ await feed.deleteFromPocket(12345);
+
+ assert.calledOnce(
+ global.NewTabUtils.activityStreamLinks.deletePocketEntry
+ );
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.deletePocketEntry,
+ 12345
+ );
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ type: at.POCKET_LINK_DELETED_OR_ARCHIVED,
+ });
+ });
+ it("should call archiveFromPocket on ARCHIVE_FROM_POCKET", async () => {
+ sandbox.stub(feed, "archiveFromPocket");
+ await feed.onAction({
+ type: at.ARCHIVE_FROM_POCKET,
+ data: { pocket_id: 12345 },
+ });
+
+ assert.calledOnce(feed.archiveFromPocket);
+ assert.calledWithExactly(feed.archiveFromPocket, 12345);
+ });
+ it("should catch if archiveFromPocket throws", async () => {
+ const e = new Error("Error");
+ global.NewTabUtils.activityStreamLinks.archivePocketEntry = sandbox
+ .stub()
+ .rejects(e);
+ await feed.archiveFromPocket(12345);
+
+ assert.calledWith(global.console.error, e);
+ });
+ it("should call NewTabUtils.archivePocketEntry and dispatch POCKET_LINK_DELETED_OR_ARCHIVED when archiving from Pocket", async () => {
+ await feed.archiveFromPocket(12345);
+
+ assert.calledOnce(
+ global.NewTabUtils.activityStreamLinks.archivePocketEntry
+ );
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.archivePocketEntry,
+ 12345
+ );
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ type: at.POCKET_LINK_DELETED_OR_ARCHIVED,
+ });
+ });
+ it("should call handoffSearchToAwesomebar on HANDOFF_SEARCH_TO_AWESOMEBAR", () => {
+ const action = {
+ type: at.HANDOFF_SEARCH_TO_AWESOMEBAR,
+ data: { text: "f" },
+ meta: { fromTarget: {} },
+ _target: { browser: { ownerGlobal: { gURLBar: { focus: () => {} } } } },
+ };
+ sinon.stub(feed, "handoffSearchToAwesomebar");
+ feed.onAction(action);
+ assert.calledWith(feed.handoffSearchToAwesomebar, action);
+ });
+ it("should call makeAttributionRequest on PARTNER_LINK_ATTRIBUTION", () => {
+ sinon.stub(feed, "makeAttributionRequest");
+ let data = { targetURL: "https://partnersite.com", source: "topsites" };
+ feed.onAction({
+ type: at.PARTNER_LINK_ATTRIBUTION,
+ data,
+ });
+
+ assert.calledOnce(feed.makeAttributionRequest);
+ assert.calledWithExactly(feed.makeAttributionRequest, data);
+ });
+ it("should call PartnerLinkAttribution.makeRequest when calling makeAttributionRequest", () => {
+ let data = { targetURL: "https://partnersite.com", source: "topsites" };
+ feed.makeAttributionRequest(data);
+ assert.calledOnce(global.PartnerLinkAttribution.makeRequest);
+ });
+ });
+
+ describe("handoffSearchToAwesomebar", () => {
+ let fakeUrlBar;
+ let listeners;
+
+ beforeEach(() => {
+ fakeUrlBar = {
+ focus: sinon.spy(),
+ handoff: sinon.spy(),
+ setHiddenFocus: sinon.spy(),
+ removeHiddenFocus: sinon.spy(),
+ addEventListener: (ev, cb) => {
+ listeners[ev] = cb;
+ },
+ removeEventListener: sinon.spy(),
+ };
+ listeners = {};
+ });
+ it("should properly handle handoff with no text passed in", () => {
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: {},
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.setHiddenFocus);
+ assert.notCalled(fakeUrlBar.handoff);
+ assert.notCalled(feed.store.dispatch);
+
+ // Now type a character.
+ listeners.keydown({ key: "f" });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledOnce(fakeUrlBar.removeHiddenFocus);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "DISABLE_SEARCH",
+ });
+ });
+ it("should properly handle handoff with text data passed in", () => {
+ const sessionId = "decafc0ffee";
+ sandbox
+ .stub(global.AboutNewTab.activityStream.store.feeds, "get")
+ .returns({
+ sessions: {
+ get: () => {
+ return { session_id: sessionId };
+ },
+ },
+ });
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: { text: "foo" },
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledWithExactly(
+ fakeUrlBar.handoff,
+ "foo",
+ global.Services.search.defaultEngine,
+ sessionId
+ );
+ assert.notCalled(fakeUrlBar.focus);
+ assert.notCalled(fakeUrlBar.setHiddenFocus);
+
+ // Now call blur listener.
+ listeners.blur();
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "SHOW_SEARCH",
+ });
+ });
+ it("should properly handle handoff with text data passed in, in private browsing mode", () => {
+ global.PrivateBrowsingUtils.isBrowserPrivate = () => true;
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: { text: "foo" },
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledWithExactly(
+ fakeUrlBar.handoff,
+ "foo",
+ global.Services.search.defaultPrivateEngine,
+ undefined
+ );
+ assert.notCalled(fakeUrlBar.focus);
+ assert.notCalled(fakeUrlBar.setHiddenFocus);
+
+ // Now call blur listener.
+ listeners.blur();
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "SHOW_SEARCH",
+ });
+ global.PrivateBrowsingUtils.isBrowserPrivate = () => false;
+ });
+ it("should SHOW_SEARCH on ESC keydown", () => {
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: { text: "foo" },
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledWithExactly(
+ fakeUrlBar.handoff,
+ "foo",
+ global.Services.search.defaultEngine,
+ undefined
+ );
+ assert.notCalled(fakeUrlBar.focus);
+
+ // Now call ESC keydown.
+ listeners.keydown({ key: "Escape" });
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "SHOW_SEARCH",
+ });
+ });
+ it("should properly handoff a newtab session id with no text passed in", () => {
+ const sessionId = "decafc0ffee";
+ sandbox
+ .stub(global.AboutNewTab.activityStream.store.feeds, "get")
+ .returns({
+ sessions: {
+ get: () => {
+ return { session_id: sessionId };
+ },
+ },
+ });
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: {},
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.setHiddenFocus);
+ assert.notCalled(fakeUrlBar.handoff);
+ assert.notCalled(feed.store.dispatch);
+
+ // Now type a character.
+ listeners.keydown({ key: "f" });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledWithExactly(
+ fakeUrlBar.handoff,
+ "",
+ global.Services.search.defaultEngine,
+ sessionId
+ );
+ assert.calledOnce(fakeUrlBar.removeHiddenFocus);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "DISABLE_SEARCH",
+ });
+ });
+ });
+
+ describe("#observe", () => {
+ it("should dispatch a PLACES_LINK_BLOCKED action with the url of the blocked link", () => {
+ feed.observe(null, BLOCKED_EVENT, "foo123.com");
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.PLACES_LINK_BLOCKED
+ );
+ assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
+ url: "foo123.com",
+ });
+ });
+ it("should not call dispatch if the topic is something other than BLOCKED_EVENT", () => {
+ feed.observe(null, "someotherevent");
+ assert.notCalled(feed.store.dispatch);
+ });
+ });
+
+ describe("Custom dispatch", () => {
+ it("should only dispatch 1 PLACES_LINKS_CHANGED action if many bookmark-added notifications happened at once", async () => {
+ // Yes, onItemAdded has at least 8 arguments. See function definition for docs.
+ const args = [
+ {
+ itemType: TYPE_BOOKMARK,
+ source: SOURCES.DEFAULT,
+ dateAdded: FAKE_BOOKMARK.dateAdded,
+ guid: FAKE_BOOKMARK.bookmarkGuid,
+ title: FAKE_BOOKMARK.bookmarkTitle,
+ url: "https://www.foo.com",
+ isTagging: false,
+ type: "bookmark-added",
+ },
+ ];
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ assert.calledOnce(
+ feed.store.dispatch.withArgs(
+ ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED })
+ )
+ );
+ });
+ it("should only dispatch 1 PLACES_LINKS_CHANGED action if many onItemRemoved notifications happened at once", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.DEFAULT,
+ type: "bookmark-removed",
+ },
+ ];
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+
+ assert.calledOnce(
+ feed.store.dispatch.withArgs(
+ ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED })
+ )
+ );
+ });
+ it("should only dispatch 1 PLACES_LINKS_CHANGED action if any page-removed notifications happened at once", async () => {
+ await feed.placesObserver.handlePlacesEvent([
+ { type: "page-removed", url: "foo.com", isRemovedFromStore: true },
+ ]);
+ await feed.placesObserver.handlePlacesEvent([
+ { type: "page-removed", url: "foo1.com", isRemovedFromStore: true },
+ ]);
+ await feed.placesObserver.handlePlacesEvent([
+ { type: "page-removed", url: "foo2.com", isRemovedFromStore: true },
+ ]);
+
+ assert.calledOnce(
+ feed.store.dispatch.withArgs(
+ ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED })
+ )
+ );
+ });
+ });
+
+ describe("PlacesObserver", () => {
+ let dispatch;
+ let observer;
+ beforeEach(() => {
+ dispatch = sandbox.spy();
+ observer = new PlacesObserver(dispatch);
+ });
+
+ describe("#history-cleared", () => {
+ it("should dispatch a PLACES_HISTORY_CLEARED action", async () => {
+ const args = [{ type: "history-cleared" }];
+ await observer.handlePlacesEvent(args);
+ assert.calledWith(dispatch, { type: at.PLACES_HISTORY_CLEARED });
+ });
+ });
+
+ describe("#page-removed", () => {
+ it("should dispatch a PLACES_LINKS_DELETED action with the right url", async () => {
+ const args = [
+ {
+ type: "page-removed",
+ url: "foo.com",
+ isRemovedFromStore: true,
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+ assert.calledWith(dispatch, {
+ type: at.PLACES_LINKS_DELETED,
+ data: { urls: ["foo.com"] },
+ });
+ });
+ });
+
+ describe("#bookmark-added", () => {
+ it("should dispatch a PLACES_BOOKMARK_ADDED action with the bookmark data - http", async () => {
+ const args = [
+ {
+ itemType: TYPE_BOOKMARK,
+ source: SOURCES.DEFAULT,
+ dateAdded: FAKE_BOOKMARK.dateAdded,
+ guid: FAKE_BOOKMARK.bookmarkGuid,
+ title: FAKE_BOOKMARK.bookmarkTitle,
+ url: "http://www.foo.com",
+ isTagging: false,
+ type: "bookmark-added",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.calledWith(dispatch.secondCall, {
+ type: at.PLACES_BOOKMARK_ADDED,
+ data: {
+ bookmarkGuid: FAKE_BOOKMARK.bookmarkGuid,
+ bookmarkTitle: FAKE_BOOKMARK.bookmarkTitle,
+ dateAdded: FAKE_BOOKMARK.dateAdded * 1000,
+ url: "http://www.foo.com",
+ },
+ });
+ });
+ it("should dispatch a PLACES_BOOKMARK_ADDED action with the bookmark data - https", async () => {
+ const args = [
+ {
+ itemType: TYPE_BOOKMARK,
+ source: SOURCES.DEFAULT,
+ dateAdded: FAKE_BOOKMARK.dateAdded,
+ guid: FAKE_BOOKMARK.bookmarkGuid,
+ title: FAKE_BOOKMARK.bookmarkTitle,
+ url: "https://www.foo.com",
+ isTagging: false,
+ type: "bookmark-added",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.calledWith(dispatch.secondCall, {
+ type: at.PLACES_BOOKMARK_ADDED,
+ data: {
+ bookmarkGuid: FAKE_BOOKMARK.bookmarkGuid,
+ bookmarkTitle: FAKE_BOOKMARK.bookmarkTitle,
+ dateAdded: FAKE_BOOKMARK.dateAdded * 1000,
+ url: "https://www.foo.com",
+ },
+ });
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - not http/https", async () => {
+ const args = [
+ {
+ itemType: TYPE_BOOKMARK,
+ source: SOURCES.DEFAULT,
+ dateAdded: FAKE_BOOKMARK.dateAdded,
+ guid: FAKE_BOOKMARK.bookmarkGuid,
+ title: FAKE_BOOKMARK.bookmarkTitle,
+ url: "foo.com",
+ isTagging: false,
+ type: "bookmark-added",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - has IMPORT source", async () => {
+ const args = [
+ {
+ itemType: TYPE_BOOKMARK,
+ source: SOURCES.IMPORT,
+ dateAdded: FAKE_BOOKMARK.dateAdded,
+ guid: FAKE_BOOKMARK.bookmarkGuid,
+ title: FAKE_BOOKMARK.bookmarkTitle,
+ url: "foo.com",
+ isTagging: false,
+ type: "bookmark-added",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - has RESTORE source", async () => {
+ const args = [
+ {
+ itemType: TYPE_BOOKMARK,
+ source: SOURCES.RESTORE,
+ dateAdded: FAKE_BOOKMARK.dateAdded,
+ guid: FAKE_BOOKMARK.bookmarkGuid,
+ title: FAKE_BOOKMARK.bookmarkTitle,
+ url: "foo.com",
+ isTagging: false,
+ type: "bookmark-added",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - has RESTORE_ON_STARTUP source", async () => {
+ const args = [
+ {
+ itemType: TYPE_BOOKMARK,
+ source: SOURCES.RESTORE_ON_STARTUP,
+ dateAdded: FAKE_BOOKMARK.dateAdded,
+ guid: FAKE_BOOKMARK.bookmarkGuid,
+ title: FAKE_BOOKMARK.bookmarkTitle,
+ url: "foo.com",
+ isTagging: false,
+ type: "bookmark-added",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - has SYNC source", async () => {
+ const args = [
+ {
+ itemType: TYPE_BOOKMARK,
+ source: SOURCES.SYNC,
+ dateAdded: FAKE_BOOKMARK.dateAdded,
+ guid: FAKE_BOOKMARK.bookmarkGuid,
+ title: FAKE_BOOKMARK.bookmarkTitle,
+ url: "foo.com",
+ isTagging: false,
+ type: "bookmark-added",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should ignore events that are not of TYPE_BOOKMARK", async () => {
+ const args = [
+ {
+ itemType: "nottypebookmark",
+ source: SOURCES.DEFAULT,
+ dateAdded: FAKE_BOOKMARK.dateAdded,
+ guid: FAKE_BOOKMARK.bookmarkGuid,
+ title: FAKE_BOOKMARK.bookmarkTitle,
+ url: "https://www.foo.com",
+ isTagging: false,
+ type: "bookmark-added",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ });
+ describe("#bookmark-removed", () => {
+ it("should ignore events that are not of TYPE_BOOKMARK", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: "nottypebookmark",
+ url: null,
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.DEFAULT,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has SYNC source", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.SYNC,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has IMPORT source", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.IMPORT,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has RESTORE source", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.RESTORE,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has RESTORE_ON_STARTUP source", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.RESTORE_ON_STARTUP,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should dispatch a PLACES_BOOKMARKS_REMOVED action with the right URL and bookmarkGuid", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.DEFAULT,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+ assert.calledWith(dispatch, {
+ type: at.PLACES_BOOKMARKS_REMOVED,
+ data: { urls: ["foo.com"] },
+ });
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PrefsFeed.test.js b/browser/components/newtab/test/unit/lib/PrefsFeed.test.js
new file mode 100644
index 0000000000..581222b3ee
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PrefsFeed.test.js
@@ -0,0 +1,357 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import { PrefsFeed } from "lib/PrefsFeed.jsm";
+
+let overrider = new GlobalOverrider();
+
+describe("PrefsFeed", () => {
+ let feed;
+ let FAKE_PREFS;
+ let sandbox;
+ let ServicesStub;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ FAKE_PREFS = new Map([
+ ["foo", 1],
+ ["bar", 2],
+ ["baz", { value: 1, skipBroadcast: true }],
+ ["qux", { value: 1, skipBroadcast: true, alsoToPreloaded: true }],
+ ]);
+ feed = new PrefsFeed(FAKE_PREFS);
+ const storage = {
+ getAll: sandbox.stub().resolves(),
+ set: sandbox.stub().resolves(),
+ };
+ ServicesStub = {
+ prefs: {
+ clearUserPref: sinon.spy(),
+ getStringPref: sinon.spy(),
+ getIntPref: sinon.spy(),
+ getBoolPref: sinon.spy(),
+ },
+ obs: {
+ removeObserver: sinon.spy(),
+ addObserver: sinon.spy(),
+ },
+ };
+ sinon.spy(feed, "_setPref");
+ feed.store = {
+ dispatch: sinon.spy(),
+ getState() {
+ return this.state;
+ },
+ dbStorage: { getDbTable: sandbox.stub().returns(storage) },
+ };
+ // Setup for tests that don't call `init`
+ feed._storage = storage;
+ feed._prefs = {
+ get: sinon.spy(item => FAKE_PREFS.get(item)),
+ set: sinon.spy((name, value) => FAKE_PREFS.set(name, value)),
+ observe: sinon.spy(),
+ observeBranch: sinon.spy(),
+ ignore: sinon.spy(),
+ ignoreBranch: sinon.spy(),
+ reset: sinon.stub(),
+ _branchStr: "branch.str.",
+ };
+ overrider.set({
+ PrivateBrowsingUtils: { enabled: true },
+ Services: ServicesStub,
+ });
+ });
+ afterEach(() => {
+ overrider.restore();
+ sandbox.restore();
+ });
+
+ it("should set a pref when a SET_PREF action is received", () => {
+ feed.onAction(ac.SetPref("foo", 2));
+ assert.calledWith(feed._prefs.set, "foo", 2);
+ });
+ it("should call clearUserPref with action CLEAR_PREF", () => {
+ feed.onAction({ type: at.CLEAR_PREF, data: { name: "pref.test" } });
+ assert.calledWith(ServicesStub.prefs.clearUserPref, "branch.str.pref.test");
+ });
+ it("should dispatch PREFS_INITIAL_VALUES on init with pref values and .isPrivateBrowsingEnabled", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.PREFS_INITIAL_VALUES
+ );
+ const [{ data }] = feed.store.dispatch.firstCall.args;
+ assert.equal(data.foo, 1);
+ assert.equal(data.bar, 2);
+ assert.isTrue(data.isPrivateBrowsingEnabled);
+ });
+ it("should dispatch PREFS_INITIAL_VALUES with a .featureConfig", () => {
+ sandbox.stub(global.NimbusFeatures.newtab, "getAllVariables").returns({
+ prefsButtonIcon: "icon-foo",
+ });
+ feed.onAction({ type: at.INIT });
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.PREFS_INITIAL_VALUES
+ );
+ const [{ data }] = feed.store.dispatch.firstCall.args;
+ assert.deepEqual(data.featureConfig, { prefsButtonIcon: "icon-foo" });
+ });
+ it("should dispatch PREFS_INITIAL_VALUES with an empty object if no experiment is returned", () => {
+ sandbox.stub(global.NimbusFeatures.newtab, "getAllVariables").returns(null);
+ feed.onAction({ type: at.INIT });
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.PREFS_INITIAL_VALUES
+ );
+ const [{ data }] = feed.store.dispatch.firstCall.args;
+ assert.deepEqual(data.featureConfig, {});
+ });
+ it("should add one branch observer on init", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(feed._prefs.observeBranch);
+ assert.calledWith(feed._prefs.observeBranch, feed);
+ });
+ it("should initialise the storage on init", () => {
+ feed.init();
+
+ assert.calledOnce(feed.store.dbStorage.getDbTable);
+ assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs");
+ });
+ it("should handle region on init", () => {
+ feed.init();
+ assert.equal(feed.geo, "US");
+ });
+ it("should add region observer on init", () => {
+ sandbox.stub(global.Region, "home").get(() => "");
+ feed.init();
+ assert.equal(feed.geo, "");
+ assert.calledWith(
+ ServicesStub.obs.addObserver,
+ feed,
+ global.Region.REGION_TOPIC
+ );
+ });
+ it("should remove the branch observer on uninit", () => {
+ feed.onAction({ type: at.UNINIT });
+ assert.calledOnce(feed._prefs.ignoreBranch);
+ assert.calledWith(feed._prefs.ignoreBranch, feed);
+ });
+ it("should call removeObserver", () => {
+ feed.geo = "";
+ feed.uninit();
+ assert.calledWith(
+ ServicesStub.obs.removeObserver,
+ feed,
+ global.Region.REGION_TOPIC
+ );
+ });
+ it("should send a PREF_CHANGED action when onPrefChanged is called", () => {
+ feed.onPrefChanged("foo", 2);
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.PREF_CHANGED,
+ data: { name: "foo", value: 2 },
+ })
+ );
+ });
+ it("should send a PREF_CHANGED actions when onPocketExperimentUpdated is called", () => {
+ sandbox
+ .stub(global.NimbusFeatures.pocketNewtab, "getAllVariables")
+ .returns({
+ prefsButtonIcon: "icon-new",
+ });
+ feed.onPocketExperimentUpdated();
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "pocketConfig",
+ value: {
+ prefsButtonIcon: "icon-new",
+ },
+ },
+ })
+ );
+ });
+ it("should not send a PREF_CHANGED actions when onPocketExperimentUpdated is called during startup", () => {
+ sandbox
+ .stub(global.NimbusFeatures.pocketNewtab, "getAllVariables")
+ .returns({
+ prefsButtonIcon: "icon-new",
+ });
+ feed.onPocketExperimentUpdated({}, "feature-experiment-loaded");
+ assert.notCalled(feed.store.dispatch);
+ feed.onPocketExperimentUpdated({}, "feature-rollout-loaded");
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should send a PREF_CHANGED actions when onExperimentUpdated is called", () => {
+ sandbox.stub(global.NimbusFeatures.newtab, "getAllVariables").returns({
+ prefsButtonIcon: "icon-new",
+ });
+ feed.onExperimentUpdated();
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "featureConfig",
+ value: {
+ prefsButtonIcon: "icon-new",
+ },
+ },
+ })
+ );
+ });
+
+ it("should remove all events on removeListeners", () => {
+ feed.geo = "";
+ sandbox.spy(global.NimbusFeatures.pocketNewtab, "offUpdate");
+ sandbox.spy(global.NimbusFeatures.newtab, "offUpdate");
+ feed.removeListeners();
+ assert.calledWith(
+ global.NimbusFeatures.pocketNewtab.offUpdate,
+ feed.onPocketExperimentUpdated
+ );
+ assert.calledWith(
+ global.NimbusFeatures.newtab.offUpdate,
+ feed.onExperimentUpdated
+ );
+ assert.calledWith(
+ ServicesStub.obs.removeObserver,
+ feed,
+ global.Region.REGION_TOPIC
+ );
+ });
+
+ it("should set storage pref on UPDATE_SECTION_PREFS", async () => {
+ await feed.onAction({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: "topsites", value: { collapsed: false } },
+ });
+ assert.calledWith(feed._storage.set, "topsites", { collapsed: false });
+ });
+ it("should set storage pref with section prefix on UPDATE_SECTION_PREFS", async () => {
+ await feed.onAction({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: "topstories", value: { collapsed: false } },
+ });
+ assert.calledWith(feed._storage.set, "feeds.section.topstories", {
+ collapsed: false,
+ });
+ });
+ it("should catch errors on UPDATE_SECTION_PREFS", async () => {
+ feed._storage.set.throws(new Error("foo"));
+ assert.doesNotThrow(async () => {
+ await feed.onAction({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: "topstories", value: { collapsed: false } },
+ });
+ });
+ });
+ it("should send OnlyToMain pref update if config for pref has skipBroadcast: true", async () => {
+ feed.onPrefChanged("baz", { value: 2, skipBroadcast: true });
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.OnlyToMain({
+ type: at.PREF_CHANGED,
+ data: { name: "baz", value: { value: 2, skipBroadcast: true } },
+ })
+ );
+ });
+ it("should send AlsoToPreloaded pref update if config for pref has skipBroadcast: true and alsoToPreloaded: true", async () => {
+ feed.onPrefChanged("qux", {
+ value: 2,
+ skipBroadcast: true,
+ alsoToPreloaded: true,
+ });
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.AlsoToPreloaded({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "qux",
+ value: { value: 2, skipBroadcast: true, alsoToPreloaded: true },
+ },
+ })
+ );
+ });
+ describe("#observe", () => {
+ it("should call dispatch from observe", () => {
+ feed.observe(undefined, global.Region.REGION_TOPIC);
+ assert.calledOnce(feed.store.dispatch);
+ });
+ });
+ describe("#_setStringPref", () => {
+ it("should call _setPref and getStringPref from _setStringPref", () => {
+ feed._setStringPref({}, "fake.pref", "default");
+ assert.calledOnce(feed._setPref);
+ assert.calledWith(
+ feed._setPref,
+ { "fake.pref": undefined },
+ "fake.pref",
+ "default"
+ );
+ assert.calledOnce(ServicesStub.prefs.getStringPref);
+ assert.calledWith(
+ ServicesStub.prefs.getStringPref,
+ "browser.newtabpage.activity-stream.fake.pref",
+ "default"
+ );
+ });
+ });
+ describe("#_setBoolPref", () => {
+ it("should call _setPref and getBoolPref from _setBoolPref", () => {
+ feed._setBoolPref({}, "fake.pref", false);
+ assert.calledOnce(feed._setPref);
+ assert.calledWith(
+ feed._setPref,
+ { "fake.pref": undefined },
+ "fake.pref",
+ false
+ );
+ assert.calledOnce(ServicesStub.prefs.getBoolPref);
+ assert.calledWith(
+ ServicesStub.prefs.getBoolPref,
+ "browser.newtabpage.activity-stream.fake.pref",
+ false
+ );
+ });
+ });
+ describe("#_setIntPref", () => {
+ it("should call _setPref and getIntPref from _setIntPref", () => {
+ feed._setIntPref({}, "fake.pref", 1);
+ assert.calledOnce(feed._setPref);
+ assert.calledWith(
+ feed._setPref,
+ { "fake.pref": undefined },
+ "fake.pref",
+ 1
+ );
+ assert.calledOnce(ServicesStub.prefs.getIntPref);
+ assert.calledWith(
+ ServicesStub.prefs.getIntPref,
+ "browser.newtabpage.activity-stream.fake.pref",
+ 1
+ );
+ });
+ });
+ describe("#_setPref", () => {
+ it("should set pref value with _setPref", () => {
+ const getPrefFunctionSpy = sinon.spy();
+ const values = {};
+ feed._setPref(values, "fake.pref", "default", getPrefFunctionSpy);
+ assert.deepEqual(values, { "fake.pref": undefined });
+ assert.calledOnce(getPrefFunctionSpy);
+ assert.calledWith(
+ getPrefFunctionSpy,
+ "browser.newtabpage.activity-stream.fake.pref",
+ "default"
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js b/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js
new file mode 100644
index 0000000000..3ddbf182c3
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js
@@ -0,0 +1,162 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { RecommendationProvider } from "lib/RecommendationProvider.jsm";
+import { combineReducers, createStore } from "redux";
+import { reducers } from "common/Reducers.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm";
+
+const PREF_PERSONALIZATION_ENABLED = "discoverystream.personalization.enabled";
+const PREF_PERSONALIZATION_MODEL_KEYS =
+ "discoverystream.personalization.modelKeys";
+describe("RecommendationProvider", () => {
+ let feed;
+ let sandbox;
+ let globals;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ globals.set({
+ PersonalityProvider,
+ });
+
+ sandbox = sinon.createSandbox();
+ feed = new RecommendationProvider();
+ feed.store = createStore(combineReducers(reducers), {});
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("#setProvider", () => {
+ it("should setup proper provider with modelKeys", async () => {
+ feed.setProvider();
+
+ assert.equal(feed.provider.modelKeys, undefined);
+
+ feed.provider = null;
+ feed._modelKeys = "1234";
+
+ feed.setProvider();
+
+ assert.equal(feed.provider.modelKeys, "1234");
+ feed._modelKeys = "12345";
+
+ // Calling it again should not rebuild the provider.
+ feed.setProvider();
+ assert.equal(feed.provider.modelKeys, "1234");
+ });
+ });
+
+ describe("#init", () => {
+ it("should init affinityProvider then refreshContent", async () => {
+ feed.provider = {
+ init: sandbox.stub().resolves(),
+ };
+ await feed.init();
+ assert.calledOnce(feed.provider.init);
+ });
+ });
+
+ describe("#getScores", () => {
+ it("should call affinityProvider.getScores", () => {
+ feed.provider = {
+ getScores: sandbox.stub().resolves(),
+ };
+ feed.getScores();
+ assert.calledOnce(feed.provider.getScores);
+ });
+ });
+
+ describe("#calculateItemRelevanceScore", () => {
+ it("should use personalized score with provider", async () => {
+ const item = {};
+ feed.provider = {
+ calculateItemRelevanceScore: async () => 0.5,
+ };
+ await feed.calculateItemRelevanceScore(item);
+ assert.equal(item.score, 0.5);
+ });
+ });
+
+ describe("#teardown", () => {
+ it("should call provider.teardown ", () => {
+ feed.provider = {
+ teardown: sandbox.stub().resolves(),
+ };
+ feed.teardown();
+ assert.calledOnce(feed.provider.teardown);
+ });
+ });
+
+ describe("#resetState", () => {
+ it("should null affinityProviderV2 and affinityProvider", () => {
+ feed._modelKeys = {};
+ feed.provider = {};
+
+ feed.resetState();
+
+ assert.equal(feed._modelKeys, null);
+ assert.equal(feed.provider, null);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => {
+ it("should call teardown, resetState, and setVersion", async () => {
+ sandbox.spy(feed, "teardown");
+ sandbox.spy(feed, "resetState");
+ feed.onAction({
+ type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
+ });
+ assert.calledOnce(feed.teardown);
+ assert.calledOnce(feed.resetState);
+ });
+ });
+
+ describe("#onAction: PREF_CHANGED", () => {
+ beforeEach(() => {
+ sandbox.spy(feed.store, "dispatch");
+ });
+ it("should dispatch to DISCOVERY_STREAM_CONFIG_RESET PREF_PERSONALIZATION_MODEL_KEYS", async () => {
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ name: PREF_PERSONALIZATION_MODEL_KEYS,
+ },
+ });
+
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.DISCOVERY_STREAM_CONFIG_RESET,
+ })
+ );
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_PERSONALIZATION_TOGGLE", () => {
+ it("should fire SET_PREF with enabled", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [PREF_PERSONALIZATION_ENABLED]: false,
+ },
+ },
+ });
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE,
+ });
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.SetPref(PREF_PERSONALIZATION_ENABLED, true)
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/Screenshots.test.js b/browser/components/newtab/test/unit/lib/Screenshots.test.js
new file mode 100644
index 0000000000..272c7ff7d3
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/Screenshots.test.js
@@ -0,0 +1,209 @@
+"use strict";
+import { GlobalOverrider } from "test/unit/utils";
+import { Screenshots } from "lib/Screenshots.jsm";
+
+const URL = "foo.com";
+const FAKE_THUMBNAIL_PATH = "fake/path/thumb.jpg";
+const FAKE_THUMBNAIL_THUMB =
+ "moz-page-thumb://thumbnail?url=http%3A%2F%2Ffoo.com%2F";
+
+describe("Screenshots", () => {
+ let globals;
+ let sandbox;
+ let fakeServices;
+ let testFile;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ fakeServices = {
+ wm: {
+ getEnumerator() {
+ return Array(10);
+ },
+ },
+ };
+ globals.set("BackgroundPageThumbs", {
+ captureIfMissing: sandbox.spy(() => Promise.resolve()),
+ });
+ globals.set("PageThumbs", {
+ _store: sandbox.stub(),
+ getThumbnailPath: sandbox.spy(() => FAKE_THUMBNAIL_PATH),
+ getThumbnailURL: sandbox.spy(() => FAKE_THUMBNAIL_THUMB),
+ });
+ globals.set("PrivateBrowsingUtils", {
+ isWindowPrivate: sandbox.spy(() => false),
+ });
+ testFile = { size: 1 };
+ globals.set("Services", fakeServices);
+ globals.set(
+ "fetch",
+ sandbox.spy(() =>
+ Promise.resolve({ blob: () => Promise.resolve(testFile) })
+ )
+ );
+ });
+ afterEach(() => {
+ globals.restore();
+ });
+
+ describe("#getScreenshotForURL", () => {
+ it("should call BackgroundPageThumbs.captureIfMissing with the correct url", async () => {
+ await Screenshots.getScreenshotForURL(URL);
+ assert.calledWith(global.BackgroundPageThumbs.captureIfMissing, URL);
+ });
+ it("should call PageThumbs.getThumbnailPath with the correct url", async () => {
+ globals.set("gPrivilegedAboutProcessEnabled", false);
+ await Screenshots.getScreenshotForURL(URL);
+ assert.calledWith(global.PageThumbs.getThumbnailPath, URL);
+ });
+ it("should call fetch", async () => {
+ await Screenshots.getScreenshotForURL(URL);
+ assert.calledOnce(global.fetch);
+ });
+ it("should have the necessary keys in the response object", async () => {
+ const screenshot = await Screenshots.getScreenshotForURL(URL);
+
+ assert.notEqual(screenshot.path, undefined);
+ assert.notEqual(screenshot.data, undefined);
+ });
+ it("should get null if something goes wrong", async () => {
+ globals.set("BackgroundPageThumbs", {
+ captureIfMissing: () =>
+ Promise.reject(new Error("Cannot capture thumbnail")),
+ });
+
+ const screenshot = await Screenshots.getScreenshotForURL(URL);
+
+ assert.calledOnce(global.PageThumbs._store);
+ assert.equal(screenshot, null);
+ });
+ it("should get direct thumbnail url for privileged process", async () => {
+ globals.set("gPrivilegedAboutProcessEnabled", true);
+ await Screenshots.getScreenshotForURL(URL);
+ assert.calledWith(global.PageThumbs.getThumbnailURL, URL);
+ });
+ it("should get null without storing if existing thumbnail is empty", async () => {
+ testFile.size = 0;
+
+ const screenshot = await Screenshots.getScreenshotForURL(URL);
+
+ assert.notCalled(global.PageThumbs._store);
+ assert.equal(screenshot, null);
+ });
+ });
+
+ describe("#maybeCacheScreenshot", () => {
+ let link;
+ beforeEach(() => {
+ link = {
+ __sharedCache: {
+ updateLink: (prop, val) => {
+ link[prop] = val;
+ },
+ },
+ };
+ });
+ it("should call getScreenshotForURL", () => {
+ sandbox.stub(Screenshots, "getScreenshotForURL");
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true);
+ Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+
+ assert.calledOnce(Screenshots.getScreenshotForURL);
+ assert.calledWithExactly(Screenshots.getScreenshotForURL, "mozilla.com");
+ });
+ it("should not call getScreenshotForURL twice if a fetch is in progress", () => {
+ sandbox
+ .stub(Screenshots, "getScreenshotForURL")
+ .returns(new Promise(() => {}));
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true);
+ Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+ Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.org",
+ "image",
+ sinon.stub()
+ );
+
+ assert.calledOnce(Screenshots.getScreenshotForURL);
+ assert.calledWithExactly(Screenshots.getScreenshotForURL, "mozilla.com");
+ });
+ it("should not call getScreenshotsForURL if property !== undefined", async () => {
+ sandbox
+ .stub(Screenshots, "getScreenshotForURL")
+ .returns(Promise.resolve(null));
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true);
+ await Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+ await Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.org",
+ "image",
+ sinon.stub()
+ );
+
+ assert.calledOnce(Screenshots.getScreenshotForURL);
+ assert.calledWithExactly(Screenshots.getScreenshotForURL, "mozilla.com");
+ });
+ it("should check if we are in private browsing before getting screenshots", async () => {
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true);
+ await Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+
+ assert.calledOnce(Screenshots._shouldGetScreenshots);
+ });
+ it("should not get a screenshot if we are in private browsing", async () => {
+ sandbox.stub(Screenshots, "getScreenshotForURL");
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(false);
+ await Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+
+ assert.notCalled(Screenshots.getScreenshotForURL);
+ });
+ });
+
+ describe("#_shouldGetScreenshots", () => {
+ beforeEach(() => {
+ let more = 2;
+ sandbox
+ .stub(global.Services.wm, "getEnumerator")
+ .callsFake(() => Array(Math.max(more--, 0)));
+ });
+ it("should use private browsing utils to determine if a window is private", () => {
+ Screenshots._shouldGetScreenshots();
+ assert.calledOnce(global.PrivateBrowsingUtils.isWindowPrivate);
+ });
+ it("should return true if there exists at least 1 non-private window", () => {
+ assert.isTrue(Screenshots._shouldGetScreenshots());
+ });
+ it("should return false if there exists private windows", () => {
+ global.PrivateBrowsingUtils = {
+ isWindowPrivate: sandbox.spy(() => true),
+ };
+ assert.isFalse(Screenshots._shouldGetScreenshots());
+ assert.calledTwice(global.PrivateBrowsingUtils.isWindowPrivate);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/SectionsManager.test.js b/browser/components/newtab/test/unit/lib/SectionsManager.test.js
new file mode 100644
index 0000000000..dc0be33180
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/SectionsManager.test.js
@@ -0,0 +1,897 @@
+"use strict";
+import {
+ actionCreators as ac,
+ actionTypes as at,
+ CONTENT_MESSAGE_TYPE,
+ MAIN_MESSAGE_TYPE,
+ PRELOAD_MESSAGE_TYPE,
+} from "common/Actions.sys.mjs";
+import { EventEmitter, GlobalOverrider } from "test/unit/utils";
+import { SectionsFeed, SectionsManager } from "lib/SectionsManager.jsm";
+
+const FAKE_ID = "FAKE_ID";
+const FAKE_OPTIONS = { icon: "FAKE_ICON", title: "FAKE_TITLE" };
+const FAKE_ROWS = [
+ { url: "1.example.com", type: "bookmark" },
+ { url: "2.example.com", type: "pocket" },
+ { url: "3.example.com", type: "history" },
+];
+const FAKE_TRENDING_ROWS = [{ url: "bar", type: "trending" }];
+const FAKE_URL = "2.example.com";
+const FAKE_CARD_OPTIONS = { title: "Some fake title" };
+
+describe("SectionsManager", () => {
+ let globals;
+ let fakeServices;
+ let fakePlacesUtils;
+ let sandbox;
+ let storage;
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ fakeServices = {
+ prefs: {
+ getBoolPref: sandbox.stub(),
+ addObserver: sandbox.stub(),
+ removeObserver: sandbox.stub(),
+ },
+ };
+ fakePlacesUtils = {
+ history: { update: sinon.stub(), insert: sinon.stub() },
+ };
+ globals.set({
+ Services: fakeServices,
+ PlacesUtils: fakePlacesUtils,
+ NimbusFeatures: {
+ newtab: { getAllVariables: sandbox.stub() },
+ pocketNewtab: { getAllVariables: sandbox.stub() },
+ },
+ });
+ // Redecorate SectionsManager to remove any listeners that have been added
+ EventEmitter.decorate(SectionsManager);
+ storage = {
+ get: sandbox.stub().resolves(),
+ set: sandbox.stub().resolves(),
+ };
+ });
+
+ afterEach(() => {
+ globals.restore();
+ sandbox.restore();
+ });
+
+ describe("#init", () => {
+ it("should initialise the sections map with the built in sections", async () => {
+ SectionsManager.sections.clear();
+ SectionsManager.initialized = false;
+ await SectionsManager.init({}, storage);
+ assert.equal(SectionsManager.sections.size, 2);
+ assert.ok(SectionsManager.sections.has("topstories"));
+ assert.ok(SectionsManager.sections.has("highlights"));
+ });
+ it("should set .initialized to true", async () => {
+ SectionsManager.sections.clear();
+ SectionsManager.initialized = false;
+ await SectionsManager.init({}, storage);
+ assert.ok(SectionsManager.initialized);
+ });
+ it("should add observer for context menu prefs", async () => {
+ SectionsManager.CONTEXT_MENU_PREFS = { MENU_ITEM: "MENU_ITEM_PREF" };
+ await SectionsManager.init({}, storage);
+ assert.calledOnce(fakeServices.prefs.addObserver);
+ assert.calledWith(
+ fakeServices.prefs.addObserver,
+ "MENU_ITEM_PREF",
+ SectionsManager
+ );
+ });
+ it("should save the reference to `storage` passed in", async () => {
+ await SectionsManager.init({}, storage);
+
+ assert.equal(SectionsManager._storage, storage);
+ });
+ });
+ describe("#uninit", () => {
+ it("should remove observer for context menu prefs", () => {
+ SectionsManager.CONTEXT_MENU_PREFS = { MENU_ITEM: "MENU_ITEM_PREF" };
+ SectionsManager.initialized = true;
+ SectionsManager.uninit();
+ assert.calledOnce(fakeServices.prefs.removeObserver);
+ assert.calledWith(
+ fakeServices.prefs.removeObserver,
+ "MENU_ITEM_PREF",
+ SectionsManager
+ );
+ assert.isFalse(SectionsManager.initialized);
+ });
+ });
+ describe("#addBuiltInSection", () => {
+ it("should not report an error if options is undefined", async () => {
+ globals.sandbox.spy(global.console, "error");
+ SectionsManager._storage.get = sandbox.stub().returns(Promise.resolve());
+ await SectionsManager.addBuiltInSection(
+ "feeds.section.topstories",
+ undefined
+ );
+
+ assert.notCalled(console.error);
+ });
+ it("should report an error if options is malformed", async () => {
+ globals.sandbox.spy(global.console, "error");
+ SectionsManager._storage.get = sandbox.stub().returns(Promise.resolve());
+ await SectionsManager.addBuiltInSection(
+ "feeds.section.topstories",
+ "invalid"
+ );
+
+ assert.calledOnce(console.error);
+ });
+ it("should not throw if the indexedDB operation fails", async () => {
+ globals.sandbox.spy(global.console, "error");
+ storage.get = sandbox.stub().throws();
+ SectionsManager._storage = storage;
+
+ try {
+ await SectionsManager.addBuiltInSection("feeds.section.topstories");
+ } catch (e) {
+ assert.fail();
+ }
+
+ assert.calledOnce(storage.get);
+ assert.calledOnce(console.error);
+ });
+ });
+ describe("#updateSectionPrefs", () => {
+ it("should update the collapsed value of the section", async () => {
+ sandbox.stub(SectionsManager, "updateSection");
+ let topstories = SectionsManager.sections.get("topstories");
+ assert.isFalse(topstories.pref.collapsed);
+
+ await SectionsManager.updateSectionPrefs("topstories", {
+ collapsed: true,
+ });
+ topstories = SectionsManager.sections.get("topstories");
+
+ assert.isTrue(SectionsManager.updateSection.args[0][1].pref.collapsed);
+ });
+ it("should ignore invalid ids", async () => {
+ sandbox.stub(SectionsManager, "updateSection");
+ await SectionsManager.updateSectionPrefs("foo", { collapsed: true });
+
+ assert.notCalled(SectionsManager.updateSection);
+ });
+ });
+ describe("#addSection", () => {
+ it("should add the id to sections and emit an ADD_SECTION event", () => {
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.ADD_SECTION, spy);
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ assert.ok(SectionsManager.sections.has(FAKE_ID));
+ assert.calledOnce(spy);
+ assert.calledWith(
+ spy,
+ SectionsManager.ADD_SECTION,
+ FAKE_ID,
+ FAKE_OPTIONS
+ );
+ });
+ });
+ describe("#removeSection", () => {
+ it("should remove the id from sections and emit an REMOVE_SECTION event", () => {
+ // Ensure we start with the id in the set
+ assert.ok(SectionsManager.sections.has(FAKE_ID));
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.REMOVE_SECTION, spy);
+ SectionsManager.removeSection(FAKE_ID);
+ assert.notOk(SectionsManager.sections.has(FAKE_ID));
+ assert.calledOnce(spy);
+ assert.calledWith(spy, SectionsManager.REMOVE_SECTION, FAKE_ID);
+ });
+ });
+ describe("#enableSection", () => {
+ it("should call updateSection with {enabled: true}", () => {
+ sinon.spy(SectionsManager, "updateSection");
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ SectionsManager.enableSection(FAKE_ID);
+ assert.calledOnce(SectionsManager.updateSection);
+ assert.calledWith(
+ SectionsManager.updateSection,
+ FAKE_ID,
+ { enabled: true },
+ true
+ );
+ SectionsManager.updateSection.restore();
+ });
+ it("should emit an ENABLE_SECTION event", () => {
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.ENABLE_SECTION, spy);
+ SectionsManager.enableSection(FAKE_ID);
+ assert.calledOnce(spy);
+ assert.calledWith(spy, SectionsManager.ENABLE_SECTION, FAKE_ID);
+ });
+ });
+ describe("#disableSection", () => {
+ it("should call updateSection with {enabled: false, rows: [], initialized: false}", () => {
+ sinon.spy(SectionsManager, "updateSection");
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ SectionsManager.disableSection(FAKE_ID);
+ assert.calledOnce(SectionsManager.updateSection);
+ assert.calledWith(
+ SectionsManager.updateSection,
+ FAKE_ID,
+ { enabled: false, rows: [], initialized: false },
+ true
+ );
+ SectionsManager.updateSection.restore();
+ });
+ it("should emit a DISABLE_SECTION event", () => {
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.DISABLE_SECTION, spy);
+ SectionsManager.disableSection(FAKE_ID);
+ assert.calledOnce(spy);
+ assert.calledWith(spy, SectionsManager.DISABLE_SECTION, FAKE_ID);
+ });
+ });
+ describe("#updateSection", () => {
+ it("should emit an UPDATE_SECTION event with correct arguments", () => {
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ const spy = sinon.spy();
+ const dedupeConfigurations = [
+ { id: "topstories", dedupeFrom: ["highlights"] },
+ ];
+ SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
+ SectionsManager.updateSection(FAKE_ID, { rows: FAKE_ROWS }, true);
+ assert.calledOnce(spy);
+ assert.calledWith(
+ spy,
+ SectionsManager.UPDATE_SECTION,
+ FAKE_ID,
+ { rows: FAKE_ROWS, dedupeConfigurations },
+ true
+ );
+ });
+ it("should do nothing if the section doesn't exist", () => {
+ SectionsManager.removeSection(FAKE_ID);
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
+ SectionsManager.updateSection(FAKE_ID, { rows: FAKE_ROWS }, true);
+ assert.notCalled(spy);
+ });
+ it("should update all sections", () => {
+ SectionsManager.sections.clear();
+ const updateSectionOrig = SectionsManager.updateSection;
+ SectionsManager.updateSection = sinon.spy();
+
+ SectionsManager.addSection("ID1", { title: "FAKE_TITLE_1" });
+ SectionsManager.addSection("ID2", { title: "FAKE_TITLE_2" });
+ SectionsManager.updateSections();
+
+ assert.calledTwice(SectionsManager.updateSection);
+ assert.calledWith(
+ SectionsManager.updateSection,
+ "ID1",
+ { title: "FAKE_TITLE_1" },
+ true
+ );
+ assert.calledWith(
+ SectionsManager.updateSection,
+ "ID2",
+ { title: "FAKE_TITLE_2" },
+ true
+ );
+ SectionsManager.updateSection = updateSectionOrig;
+ });
+ it("context menu pref change should update sections", async () => {
+ let observer;
+ const services = {
+ prefs: {
+ getBoolPref: sinon.spy(),
+ addObserver: (pref, o) => (observer = o),
+ removeObserver: sinon.spy(),
+ },
+ };
+ globals.set("Services", services);
+
+ SectionsManager.updateSections = sinon.spy();
+ SectionsManager.CONTEXT_MENU_PREFS = { MENU_ITEM: "MENU_ITEM_PREF" };
+ await SectionsManager.init({}, storage);
+ observer.observe("", "nsPref:changed", "MENU_ITEM_PREF");
+
+ assert.calledOnce(SectionsManager.updateSections);
+ });
+ });
+ describe("#_addCardTypeLinkMenuOptions", () => {
+ const addCardTypeLinkMenuOptionsOrig =
+ SectionsManager._addCardTypeLinkMenuOptions;
+ const contextMenuOptionsOrig =
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES;
+ beforeEach(() => {
+ // Add a topstories section and a highlights section, with types for each card
+ SectionsManager.addSection("topstories", { FAKE_TRENDING_ROWS });
+ SectionsManager.addSection("highlights", { FAKE_ROWS });
+ });
+ it("should only call _addCardTypeLinkMenuOptions if the section update is for highlights", () => {
+ SectionsManager._addCardTypeLinkMenuOptions = sinon.spy();
+ SectionsManager.updateSection("topstories", { rows: FAKE_ROWS }, false);
+ assert.notCalled(SectionsManager._addCardTypeLinkMenuOptions);
+
+ SectionsManager.updateSection("highlights", { rows: FAKE_ROWS }, false);
+ assert.calledWith(SectionsManager._addCardTypeLinkMenuOptions, FAKE_ROWS);
+ });
+ it("should only call _addCardTypeLinkMenuOptions if the section update has rows", () => {
+ SectionsManager._addCardTypeLinkMenuOptions = sinon.spy();
+ SectionsManager.updateSection("highlights", {}, false);
+ assert.notCalled(SectionsManager._addCardTypeLinkMenuOptions);
+ });
+ it("should assign the correct context menu options based on the type of highlight", () => {
+ SectionsManager._addCardTypeLinkMenuOptions =
+ addCardTypeLinkMenuOptionsOrig;
+
+ SectionsManager.updateSection("highlights", { rows: FAKE_ROWS }, false);
+ const highlights = SectionsManager.sections.get("highlights").FAKE_ROWS;
+
+ // FAKE_ROWS was added in the following order: bookmark, pocket, history
+ assert.deepEqual(
+ highlights[0].contextMenuOptions,
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES.bookmark
+ );
+ assert.deepEqual(
+ highlights[1].contextMenuOptions,
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES.pocket
+ );
+ assert.deepEqual(
+ highlights[2].contextMenuOptions,
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES.history
+ );
+ });
+ it("should throw an error if you are assigning a context menu to a non-existant highlight type", () => {
+ globals.sandbox.spy(global.console, "error");
+ SectionsManager.updateSection(
+ "highlights",
+ { rows: [{ url: "foo", type: "badtype" }] },
+ false
+ );
+ const highlights = SectionsManager.sections.get("highlights").rows;
+ assert.calledOnce(console.error);
+ assert.equal(highlights[0].contextMenuOptions, undefined);
+ });
+ it("should filter out context menu options that are in CONTEXT_MENU_PREFS", () => {
+ const services = {
+ prefs: {
+ getBoolPref: o =>
+ SectionsManager.CONTEXT_MENU_PREFS[o] !== "RemoveMe",
+ addObserver() {},
+ removeObserver() {},
+ },
+ };
+ globals.set("Services", services);
+ SectionsManager.CONTEXT_MENU_PREFS = { RemoveMe: "RemoveMe" };
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES = {
+ bookmark: ["KeepMe", "RemoveMe"],
+ pocket: ["KeepMe", "RemoveMe"],
+ history: ["KeepMe", "RemoveMe"],
+ };
+ SectionsManager.updateSection("highlights", { rows: FAKE_ROWS }, false);
+ const highlights = SectionsManager.sections.get("highlights").FAKE_ROWS;
+
+ // Only keep context menu options that were not supposed to be removed based on CONTEXT_MENU_PREFS
+ assert.deepEqual(highlights[0].contextMenuOptions, ["KeepMe"]);
+ assert.deepEqual(highlights[1].contextMenuOptions, ["KeepMe"]);
+ assert.deepEqual(highlights[2].contextMenuOptions, ["KeepMe"]);
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES =
+ contextMenuOptionsOrig;
+ globals.restore();
+ });
+ });
+ describe("#onceInitialized", () => {
+ it("should call the callback immediately if SectionsManager is initialised", () => {
+ SectionsManager.initialized = true;
+ const callback = sinon.spy();
+ SectionsManager.onceInitialized(callback);
+ assert.calledOnce(callback);
+ });
+ it("should bind the callback to .once(INIT) if SectionsManager is not initialised", () => {
+ SectionsManager.initialized = false;
+ sinon.spy(SectionsManager, "once");
+ const callback = () => {};
+ SectionsManager.onceInitialized(callback);
+ assert.calledOnce(SectionsManager.once);
+ assert.calledWith(SectionsManager.once, SectionsManager.INIT, callback);
+ });
+ });
+ describe("#updateSectionCard", () => {
+ it("should emit an UPDATE_SECTION_CARD event with correct arguments", () => {
+ SectionsManager.addSection(
+ FAKE_ID,
+ Object.assign({}, FAKE_OPTIONS, { rows: FAKE_ROWS })
+ );
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION_CARD, spy);
+ SectionsManager.updateSectionCard(
+ FAKE_ID,
+ FAKE_URL,
+ FAKE_CARD_OPTIONS,
+ true
+ );
+ assert.calledOnce(spy);
+ assert.calledWith(
+ spy,
+ SectionsManager.UPDATE_SECTION_CARD,
+ FAKE_ID,
+ FAKE_URL,
+ FAKE_CARD_OPTIONS,
+ true
+ );
+ });
+ it("should do nothing if the section doesn't exist", () => {
+ SectionsManager.removeSection(FAKE_ID);
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION_CARD, spy);
+ SectionsManager.updateSectionCard(
+ FAKE_ID,
+ FAKE_URL,
+ FAKE_CARD_OPTIONS,
+ true
+ );
+ assert.notCalled(spy);
+ });
+ });
+ describe("#removeSectionCard", () => {
+ it("should dispatch an SECTION_UPDATE action in which cards corresponding to the given url are removed", () => {
+ const rows = [{ url: "foo.com" }, { url: "bar.com" }];
+
+ SectionsManager.addSection(
+ FAKE_ID,
+ Object.assign({}, FAKE_OPTIONS, { rows })
+ );
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
+ SectionsManager.removeSectionCard(FAKE_ID, "foo.com");
+
+ assert.calledOnce(spy);
+ assert.equal(spy.firstCall.args[1], FAKE_ID);
+ assert.deepEqual(spy.firstCall.args[2].rows, [{ url: "bar.com" }]);
+ });
+ it("should do nothing if the section doesn't exist", () => {
+ SectionsManager.removeSection(FAKE_ID);
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
+ SectionsManager.removeSectionCard(FAKE_ID, "bar.com");
+ assert.notCalled(spy);
+ });
+ });
+ describe("#updateBookmarkMetadata", () => {
+ beforeEach(() => {
+ let rows = [
+ {
+ url: "bar",
+ title: "title",
+ description: "description",
+ image: "image",
+ type: "trending",
+ },
+ ];
+ SectionsManager.addSection("topstories", { rows });
+ // Simulate 2 sections.
+ rows = [
+ {
+ url: "foo",
+ title: "title",
+ description: "description",
+ image: "image",
+ type: "bookmark",
+ },
+ ];
+ SectionsManager.addSection("highlights", { rows });
+ });
+
+ it("shouldn't call PlacesUtils if URL is not in topstories", () => {
+ SectionsManager.updateBookmarkMetadata({ url: "foo" });
+
+ assert.notCalled(fakePlacesUtils.history.update);
+ });
+ it("should call PlacesUtils.history.update", () => {
+ SectionsManager.updateBookmarkMetadata({ url: "bar" });
+
+ assert.calledOnce(fakePlacesUtils.history.update);
+ assert.calledWithExactly(fakePlacesUtils.history.update, {
+ url: "bar",
+ title: "title",
+ description: "description",
+ previewImageURL: "image",
+ });
+ });
+ it("should call PlacesUtils.history.insert", () => {
+ SectionsManager.updateBookmarkMetadata({ url: "bar" });
+
+ assert.calledOnce(fakePlacesUtils.history.insert);
+ assert.calledWithExactly(fakePlacesUtils.history.insert, {
+ url: "bar",
+ title: "title",
+ visits: [{}],
+ });
+ });
+ });
+});
+
+describe("SectionsFeed", () => {
+ let feed;
+ let sandbox;
+ let storage;
+ let globals;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ SectionsManager.sections.clear();
+ SectionsManager.initialized = false;
+ globals = new GlobalOverrider();
+ globals.set("NimbusFeatures", {
+ newtab: { getAllVariables: sandbox.stub() },
+ pocketNewtab: { getAllVariables: sandbox.stub() },
+ });
+ storage = {
+ get: sandbox.stub().resolves(),
+ set: sandbox.stub().resolves(),
+ };
+ feed = new SectionsFeed();
+ feed.store = { dispatch: sinon.spy() };
+ feed.store = {
+ dispatch: sinon.spy(),
+ getState() {
+ return this.state;
+ },
+ state: {
+ Prefs: {
+ values: {
+ sectionOrder: "topsites,topstories,highlights",
+ "feeds.topsites": true,
+ },
+ },
+ Sections: [{ initialized: false }],
+ },
+ dbStorage: { getDbTable: sandbox.stub().returns(storage) },
+ };
+ });
+ afterEach(() => {
+ feed.uninit();
+ globals.restore();
+ });
+ describe("#init", () => {
+ it("should create a SectionsFeed", () => {
+ assert.instanceOf(feed, SectionsFeed);
+ });
+ it("should bind appropriate listeners", () => {
+ sinon.spy(SectionsManager, "on");
+ feed.init();
+ assert.callCount(SectionsManager.on, 4);
+ for (const [event, listener] of [
+ [SectionsManager.ADD_SECTION, feed.onAddSection],
+ [SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
+ [SectionsManager.UPDATE_SECTION, feed.onUpdateSection],
+ [SectionsManager.UPDATE_SECTION_CARD, feed.onUpdateSectionCard],
+ ]) {
+ assert.calledWith(SectionsManager.on, event, listener);
+ }
+ });
+ it("should call onAddSection for any already added sections in SectionsManager", async () => {
+ await SectionsManager.init({}, storage);
+ assert.ok(SectionsManager.sections.has("topstories"));
+ assert.ok(SectionsManager.sections.has("highlights"));
+ const topstories = SectionsManager.sections.get("topstories");
+ const highlights = SectionsManager.sections.get("highlights");
+ sinon.spy(feed, "onAddSection");
+ feed.init();
+ assert.calledTwice(feed.onAddSection);
+ assert.calledWith(
+ feed.onAddSection,
+ SectionsManager.ADD_SECTION,
+ "topstories",
+ topstories
+ );
+ assert.calledWith(
+ feed.onAddSection,
+ SectionsManager.ADD_SECTION,
+ "highlights",
+ highlights
+ );
+ });
+ });
+ describe("#uninit", () => {
+ it("should unbind all listeners", () => {
+ sinon.spy(SectionsManager, "off");
+ feed.init();
+ feed.uninit();
+ assert.callCount(SectionsManager.off, 4);
+ for (const [event, listener] of [
+ [SectionsManager.ADD_SECTION, feed.onAddSection],
+ [SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
+ [SectionsManager.UPDATE_SECTION, feed.onUpdateSection],
+ [SectionsManager.UPDATE_SECTION_CARD, feed.onUpdateSectionCard],
+ ]) {
+ assert.calledWith(SectionsManager.off, event, listener);
+ }
+ });
+ it("should emit an UNINIT event and set SectionsManager.initialized to false", () => {
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UNINIT, spy);
+ feed.init();
+ feed.uninit();
+ assert.calledOnce(spy);
+ assert.notOk(SectionsManager.initialized);
+ });
+ });
+ describe("#onAddSection", () => {
+ it("should broadcast a SECTION_REGISTER action with the correct data", () => {
+ feed.onAddSection(null, FAKE_ID, FAKE_OPTIONS);
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_REGISTER");
+ assert.deepEqual(
+ action.data,
+ Object.assign({ id: FAKE_ID }, FAKE_OPTIONS)
+ );
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ it("should prepend id to sectionOrder pref if not already included", () => {
+ feed.store.state.Sections = [
+ { id: "topstories", enabled: true },
+ { id: "highlights", enabled: true },
+ ];
+ feed.onAddSection(null, FAKE_ID, FAKE_OPTIONS);
+ assert.calledWith(feed.store.dispatch, {
+ data: {
+ name: "sectionOrder",
+ value: `${FAKE_ID},topsites,topstories,highlights`,
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ });
+ describe("#onRemoveSection", () => {
+ it("should broadcast a SECTION_DEREGISTER action with the correct data", () => {
+ feed.onRemoveSection(null, FAKE_ID);
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_DEREGISTER");
+ assert.deepEqual(action.data, FAKE_ID);
+ // Should be broadcast
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ });
+ describe("#onUpdateSection", () => {
+ it("should do nothing if no options are provided", () => {
+ feed.onUpdateSection(null, FAKE_ID, null);
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should dispatch a SECTION_UPDATE action with the correct data", () => {
+ feed.onUpdateSection(null, FAKE_ID, { rows: FAKE_ROWS });
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_UPDATE");
+ assert.deepEqual(action.data, { id: FAKE_ID, rows: FAKE_ROWS });
+ // Should be not broadcast by default, but should update the preloaded tab, so check meta
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, PRELOAD_MESSAGE_TYPE);
+ });
+ it("should broadcast the action only if shouldBroadcast is true", () => {
+ feed.onUpdateSection(null, FAKE_ID, { rows: FAKE_ROWS }, true);
+ const [action] = feed.store.dispatch.firstCall.args;
+ // Should be broadcast
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ });
+ describe("#onUpdateSectionCard", () => {
+ it("should do nothing if no options are provided", () => {
+ feed.onUpdateSectionCard(null, FAKE_ID, FAKE_URL, null);
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should dispatch a SECTION_UPDATE_CARD action with the correct data", () => {
+ feed.onUpdateSectionCard(null, FAKE_ID, FAKE_URL, FAKE_CARD_OPTIONS);
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_UPDATE_CARD");
+ assert.deepEqual(action.data, {
+ id: FAKE_ID,
+ url: FAKE_URL,
+ options: FAKE_CARD_OPTIONS,
+ });
+ // Should be not broadcast by default, but should update the preloaded tab, so check meta
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, PRELOAD_MESSAGE_TYPE);
+ });
+ it("should broadcast the action only if shouldBroadcast is true", () => {
+ feed.onUpdateSectionCard(
+ null,
+ FAKE_ID,
+ FAKE_URL,
+ FAKE_CARD_OPTIONS,
+ true
+ );
+ const [action] = feed.store.dispatch.firstCall.args;
+ // Should be broadcast
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ });
+ describe("#onAction", () => {
+ it("should bind this.init to SectionsManager.INIT on INIT", () => {
+ sinon.spy(SectionsManager, "once");
+ feed.onAction({ type: "INIT" });
+ assert.calledOnce(SectionsManager.once);
+ assert.calledWith(SectionsManager.once, SectionsManager.INIT, feed.init);
+ });
+ it("should call SectionsManager.init on action PREFS_INITIAL_VALUES", () => {
+ sinon.spy(SectionsManager, "init");
+ feed.onAction({ type: "PREFS_INITIAL_VALUES", data: { foo: "bar" } });
+ assert.calledOnce(SectionsManager.init);
+ assert.calledWith(SectionsManager.init, { foo: "bar" });
+ assert.calledOnce(feed.store.dbStorage.getDbTable);
+ assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs");
+ });
+ it("should call SectionsManager.addBuiltInSection on suitable PREF_CHANGED events", () => {
+ sinon.spy(SectionsManager, "addBuiltInSection");
+ feed.onAction({
+ type: "PREF_CHANGED",
+ data: { name: "feeds.section.topstories.options", value: "foo" },
+ });
+ assert.calledOnce(SectionsManager.addBuiltInSection);
+ assert.calledWith(
+ SectionsManager.addBuiltInSection,
+ "feeds.section.topstories",
+ "foo"
+ );
+ });
+ it("should fire SECTION_OPTIONS_UPDATED on suitable PREF_CHANGED events", async () => {
+ await feed.onAction({
+ type: "PREF_CHANGED",
+ data: { name: "feeds.section.topstories.options", value: "foo" },
+ });
+ assert.calledOnce(feed.store.dispatch);
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_OPTIONS_CHANGED");
+ assert.equal(action.data, "topstories");
+ });
+ it("should call SectionsManager.disableSection on SECTION_DISABLE", () => {
+ sinon.spy(SectionsManager, "disableSection");
+ feed.onAction({ type: "SECTION_DISABLE", data: 1234 });
+ assert.calledOnce(SectionsManager.disableSection);
+ assert.calledWith(SectionsManager.disableSection, 1234);
+ SectionsManager.disableSection.restore();
+ });
+ it("should call SectionsManager.enableSection on SECTION_ENABLE", () => {
+ sinon.spy(SectionsManager, "enableSection");
+ feed.onAction({ type: "SECTION_ENABLE", data: 1234 });
+ assert.calledOnce(SectionsManager.enableSection);
+ assert.calledWith(SectionsManager.enableSection, 1234);
+ SectionsManager.enableSection.restore();
+ });
+ it("should call the feed's uninit on UNINIT", () => {
+ sinon.stub(feed, "uninit");
+
+ feed.onAction({ type: "UNINIT" });
+
+ assert.calledOnce(feed.uninit);
+ });
+ it("should emit a ACTION_DISPATCHED event and forward any action in ACTIONS_TO_PROXY if there are any sections", () => {
+ const spy = sinon.spy();
+ const allowedActions = SectionsManager.ACTIONS_TO_PROXY;
+ const disallowedActions = ["PREF_CHANGED", "OPEN_PRIVATE_WINDOW"];
+ feed.init();
+ SectionsManager.on(SectionsManager.ACTION_DISPATCHED, spy);
+ // Make sure we start with no sections - no event should be emitted
+ SectionsManager.sections.clear();
+ feed.onAction({ type: allowedActions[0] });
+ assert.notCalled(spy);
+ // Then add a section and check correct behaviour
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ for (const action of allowedActions.concat(disallowedActions)) {
+ feed.onAction({ type: action });
+ }
+ for (const action of allowedActions) {
+ assert.calledWith(spy, "ACTION_DISPATCHED", action);
+ }
+ for (const action of disallowedActions) {
+ assert.neverCalledWith(spy, "ACTION_DISPATCHED", action);
+ }
+ });
+ it("should call updateBookmarkMetadata on PLACES_BOOKMARK_ADDED", () => {
+ const stub = sinon.stub(SectionsManager, "updateBookmarkMetadata");
+
+ feed.onAction({ type: "PLACES_BOOKMARK_ADDED", data: {} });
+
+ assert.calledOnce(stub);
+ });
+ it("should call updateSectionPrefs on UPDATE_SECTION_PREFS", () => {
+ const stub = sinon.stub(SectionsManager, "updateSectionPrefs");
+
+ feed.onAction({ type: "UPDATE_SECTION_PREFS", data: {} });
+
+ assert.calledOnce(stub);
+ });
+ it("should call SectionManager.removeSectionCard on WEBEXT_DISMISS", () => {
+ const stub = sinon.stub(SectionsManager, "removeSectionCard");
+
+ feed.onAction(
+ ac.WebExtEvent(at.WEBEXT_DISMISS, { source: "Foo", url: "bar.com" })
+ );
+
+ assert.calledOnce(stub);
+ assert.calledWith(stub, "Foo", "bar.com");
+ });
+ it("should call the feed's moveSection on SECTION_MOVE", () => {
+ sinon.stub(feed, "moveSection");
+ const id = "topsites";
+ const direction = +1;
+ feed.onAction({ type: "SECTION_MOVE", data: { id, direction } });
+
+ assert.calledOnce(feed.moveSection);
+ assert.calledWith(feed.moveSection, id, direction);
+ });
+ });
+ describe("#moveSection", () => {
+ it("should Move Down correctly", () => {
+ feed.store.state.Sections = [
+ { id: "topstories", enabled: true },
+ { id: "highlights", enabled: true },
+ ];
+ feed.moveSection("topsites", +1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topstories,topsites,highlights" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ feed.store.dispatch.resetHistory();
+ feed.moveSection("topstories", +1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topsites,highlights,topstories" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ it("should Move Up correctly", () => {
+ feed.store.state.Sections = [
+ { id: "topstories", enabled: true },
+ { id: "highlights", enabled: true },
+ ];
+ feed.moveSection("topstories", -1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topstories,topsites,highlights" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ feed.store.dispatch.resetHistory();
+ feed.moveSection("highlights", -1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topsites,highlights,topstories" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ it("should skip over sections that aren't enabled", () => {
+ feed.store.state.Sections = [
+ { id: "topstories", enabled: false },
+ { id: "highlights", enabled: true },
+ ];
+ feed.moveSection("highlights", -1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "highlights,topsites,topstories" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ feed.store.dispatch.resetHistory();
+ feed.moveSection("topsites", +1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topstories,highlights,topsites" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ShortUrl.test.js b/browser/components/newtab/test/unit/lib/ShortUrl.test.js
new file mode 100644
index 0000000000..e0f6688db8
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ShortUrl.test.js
@@ -0,0 +1,104 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { shortURL } from "lib/ShortURL.jsm";
+
+const puny = "xn--kpry57d";
+const idn = "台灣";
+
+describe("shortURL", () => {
+ let globals;
+ let IDNStub;
+ let getPublicSuffixFromHostStub;
+
+ beforeEach(() => {
+ IDNStub = sinon.stub().callsFake(host => host.replace(puny, idn));
+ getPublicSuffixFromHostStub = sinon.stub().returns("com");
+
+ globals = new GlobalOverrider();
+ globals.set("IDNService", { convertToDisplayIDN: IDNStub });
+ globals.set("Services", {
+ eTLD: { getPublicSuffixFromHost: getPublicSuffixFromHostStub },
+ });
+ });
+
+ afterEach(() => {
+ globals.restore();
+ });
+
+ it("should return a blank string if url is falsey", () => {
+ assert.equal(shortURL({ url: false }), "");
+ assert.equal(shortURL({ url: "" }), "");
+ assert.equal(shortURL({}), "");
+ });
+
+ it("should return the 'url' if not a valid url", () => {
+ const checkInvalid = url => assert.equal(shortURL({ url }), url);
+ checkInvalid(true);
+ checkInvalid("something");
+ checkInvalid("http:");
+ checkInvalid("http::double");
+ checkInvalid("http://badport:65536/");
+ });
+
+ it("should remove the eTLD", () => {
+ assert.equal(shortURL({ url: "http://com.blah.com" }), "com.blah");
+ });
+
+ it("should convert host to idn when calling shortURL", () => {
+ assert.equal(shortURL({ url: `http://${puny}.blah.com` }), `${idn}.blah`);
+ });
+
+ it("should get the hostname from .url", () => {
+ assert.equal(shortURL({ url: "http://bar.com" }), "bar");
+ });
+
+ it("should not strip out www if not first subdomain", () => {
+ assert.equal(shortURL({ url: "http://foo.www.com" }), "foo.www");
+ });
+
+ it("should convert to lowercase", () => {
+ assert.equal(shortURL({ url: "HTTP://FOO.COM" }), "foo");
+ });
+
+ it("should not include the port", () => {
+ assert.equal(shortURL({ url: "http://foo.com:8888" }), "foo");
+ });
+
+ it("should return hostname for localhost", () => {
+ getPublicSuffixFromHostStub.throws("insufficient domain levels");
+
+ assert.equal(shortURL({ url: "http://localhost:8000/" }), "localhost");
+ });
+
+ it("should return hostname for ip address", () => {
+ getPublicSuffixFromHostStub.throws("host is ip address");
+
+ assert.equal(shortURL({ url: "http://127.0.0.1/foo" }), "127.0.0.1");
+ });
+
+ it("should return etld for www.gov.uk (www-only non-etld)", () => {
+ getPublicSuffixFromHostStub.returns("gov.uk");
+
+ assert.equal(
+ shortURL({ url: "https://www.gov.uk/countersigning" }),
+ "gov.uk"
+ );
+ });
+
+ it("should return idn etld for www-only non-etld", () => {
+ getPublicSuffixFromHostStub.returns(puny);
+
+ assert.equal(shortURL({ url: `https://www.${puny}/foo` }), idn);
+ });
+
+ it("should return not the protocol for file:", () => {
+ assert.equal(shortURL({ url: "file:///foo/bar.txt" }), "/foo/bar.txt");
+ });
+
+ it("should return not the protocol for about:", () => {
+ assert.equal(shortURL({ url: "about:newtab" }), "newtab");
+ });
+
+ it("should fall back to full url as a last resort", () => {
+ assert.equal(shortURL({ url: "about:" }), "about:");
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/SiteClassifier.test.js b/browser/components/newtab/test/unit/lib/SiteClassifier.test.js
new file mode 100644
index 0000000000..a8b09ce1f0
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/SiteClassifier.test.js
@@ -0,0 +1,252 @@
+import { classifySite } from "lib/SiteClassifier.jsm";
+
+const FAKE_CLASSIFIER_DATA = [
+ {
+ type: "hostname-and-params-match",
+ criteria: [
+ {
+ hostname: "hostnameandparams.com",
+ params: [
+ {
+ key: "param1",
+ value: "val1",
+ },
+ ],
+ },
+ ],
+ weight: 300,
+ },
+ {
+ type: "url-match",
+ criteria: [{ url: "https://fullurl.com/must/match" }],
+ weight: 400,
+ },
+ {
+ type: "params-match",
+ criteria: [
+ {
+ params: [
+ {
+ key: "param1",
+ value: "val1",
+ },
+ {
+ key: "param2",
+ value: "val2",
+ },
+ ],
+ },
+ ],
+ weight: 200,
+ },
+ {
+ type: "params-prefix-match",
+ criteria: [
+ {
+ params: [
+ {
+ key: "client",
+ prefix: "fir",
+ },
+ ],
+ },
+ ],
+ weight: 200,
+ },
+ {
+ type: "has-params",
+ criteria: [
+ {
+ params: [{ key: "has-param1" }, { key: "has-param2" }],
+ },
+ ],
+ weight: 100,
+ },
+ {
+ type: "search-engine",
+ criteria: [
+ { sld: "google" },
+ { hostname: "bing.com" },
+ { hostname: "duckduckgo.com" },
+ ],
+ weight: 1,
+ },
+ {
+ type: "news-portal",
+ criteria: [
+ { hostname: "yahoo.com" },
+ { hostname: "aol.com" },
+ { hostname: "msn.com" },
+ ],
+ weight: 1,
+ },
+ {
+ type: "social-media",
+ criteria: [{ hostname: "facebook.com" }, { hostname: "twitter.com" }],
+ weight: 1,
+ },
+ {
+ type: "ecommerce",
+ criteria: [{ sld: "amazon" }, { hostname: "ebay.com" }],
+ weight: 1,
+ },
+];
+
+describe("SiteClassifier", () => {
+ function RemoteSettings() {
+ return {
+ get() {
+ return Promise.resolve(FAKE_CLASSIFIER_DATA);
+ },
+ };
+ }
+
+ it("should return the right category", async () => {
+ assert.equal(
+ "hostname-and-params-match",
+ await classifySite(
+ "https://hostnameandparams.com?param1=val1",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite(
+ "https://hostnameandparams.com?param1=val",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite(
+ "https://hostnameandparams.com?param=val1",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://hostnameandparams.com", RemoteSettings)
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://params.com?param1=val1", RemoteSettings)
+ );
+
+ assert.equal(
+ "url-match",
+ await classifySite("https://fullurl.com/must/match", RemoteSettings)
+ );
+ assert.equal(
+ "other",
+ await classifySite("http://fullurl.com/must/match", RemoteSettings)
+ );
+
+ assert.equal(
+ "params-match",
+ await classifySite(
+ "https://example.com?param1=val1&param2=val2",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "params-match",
+ await classifySite(
+ "https://example.com?param1=val1&param2=val2&other=other",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite(
+ "https://example.com?param1=val2&param2=val1",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://example.com?param1&param2", RemoteSettings)
+ );
+
+ assert.equal(
+ "params-prefix-match",
+ await classifySite("https://search.com?client=firefox", RemoteSettings)
+ );
+ assert.equal(
+ "params-prefix-match",
+ await classifySite("https://search.com?client=fir", RemoteSettings)
+ );
+ assert.equal(
+ "other",
+ await classifySite(
+ "https://search.com?client=mozillafirefox",
+ RemoteSettings
+ )
+ );
+
+ assert.equal(
+ "has-params",
+ await classifySite(
+ "https://example.com?has-param1=val1&has-param2=val2",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "has-params",
+ await classifySite(
+ "https://example.com?has-param1&has-param2",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "has-params",
+ await classifySite(
+ "https://example.com?has-param1&has-param2&other=other",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://example.com?has-param1", RemoteSettings)
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://example.com?has-param2", RemoteSettings)
+ );
+
+ assert.equal(
+ "search-engine",
+ await classifySite("https://google.com", RemoteSettings)
+ );
+ assert.equal(
+ "search-engine",
+ await classifySite("https://google.de", RemoteSettings)
+ );
+ assert.equal(
+ "search-engine",
+ await classifySite("http://bing.com/?q=firefox", RemoteSettings)
+ );
+
+ assert.equal(
+ "news-portal",
+ await classifySite("https://yahoo.com", RemoteSettings)
+ );
+
+ assert.equal(
+ "social-media",
+ await classifySite("http://twitter.com/firefox", RemoteSettings)
+ );
+
+ assert.equal(
+ "ecommerce",
+ await classifySite("https://amazon.com", RemoteSettings)
+ );
+ assert.equal(
+ "ecommerce",
+ await classifySite("https://amazon.ca", RemoteSettings)
+ );
+ assert.equal(
+ "ecommerce",
+ await classifySite("https://ebay.com", RemoteSettings)
+ );
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/Store.test.js b/browser/components/newtab/test/unit/lib/Store.test.js
new file mode 100644
index 0000000000..eeeef3bf51
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/Store.test.js
@@ -0,0 +1,305 @@
+import { addNumberReducer, FakePrefs } from "test/unit/utils";
+import { createStore } from "redux";
+import injector from "inject!lib/Store.jsm";
+
+describe("Store", () => {
+ let Store;
+ let sandbox;
+ let store;
+ let dbStub;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ function ActivityStreamMessageChannel(options) {
+ this.dispatch = options.dispatch;
+ this.createChannel = sandbox.spy();
+ this.destroyChannel = sandbox.spy();
+ this.middleware = sandbox.spy(s => next => action => next(action));
+ this.simulateMessagesForExistingTabs = sandbox.stub();
+ }
+ dbStub = sandbox.stub().resolves();
+ function FakeActivityStreamStorage() {
+ this.db = {};
+ sinon.stub(this, "db").get(dbStub);
+ }
+ ({ Store } = injector({
+ "lib/ActivityStreamMessageChannel.jsm": { ActivityStreamMessageChannel },
+ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
+ "lib/ActivityStreamStorage.jsm": {
+ ActivityStreamStorage: FakeActivityStreamStorage,
+ },
+ }));
+ store = new Store();
+ sandbox.stub(store, "_initIndexedDB").resolves();
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should have a .feeds property that is a Map", () => {
+ assert.instanceOf(store.feeds, Map);
+ assert.equal(store.feeds.size, 0, ".feeds.size");
+ });
+ it("should have a redux store at ._store", () => {
+ assert.ok(store._store);
+ assert.property(store, "dispatch");
+ assert.property(store, "getState");
+ });
+ it("should create a ActivityStreamMessageChannel with the right dispatcher", () => {
+ assert.ok(store.getMessageChannel());
+ assert.equal(store.getMessageChannel().dispatch, store.dispatch);
+ assert.equal(store.getMessageChannel(), store._messageChannel);
+ });
+ it("should connect the ActivityStreamMessageChannel's middleware", () => {
+ store.dispatch({ type: "FOO" });
+ assert.calledOnce(store._messageChannel.middleware);
+ });
+ describe("#initFeed", () => {
+ it("should add an instance of the feed to .feeds", () => {
+ class Foo {}
+ store._prefs.set("foo", true);
+ store.init(new Map([["foo", () => new Foo()]]));
+ store.initFeed("foo");
+
+ assert.isTrue(store.feeds.has("foo"), "foo is set");
+ assert.instanceOf(store.feeds.get("foo"), Foo);
+ });
+ it("should call the feed's onAction with uninit action if it exists", () => {
+ let feed;
+ function createFeed() {
+ feed = { onAction: sinon.spy() };
+ return feed;
+ }
+ const action = { type: "FOO" };
+ store._feedFactories = new Map([["foo", createFeed]]);
+
+ store.initFeed("foo", action);
+
+ assert.calledOnce(feed.onAction);
+ assert.calledWith(feed.onAction, action);
+ });
+ it("should add a .store property to the feed", () => {
+ class Foo {}
+ store._feedFactories = new Map([["foo", () => new Foo()]]);
+ store.initFeed("foo");
+
+ assert.propertyVal(store.feeds.get("foo"), "store", store);
+ });
+ });
+ describe("#uninitFeed", () => {
+ it("should not throw if no feed with that name exists", () => {
+ assert.doesNotThrow(() => {
+ store.uninitFeed("bar");
+ });
+ });
+ it("should call the feed's onAction with uninit action if it exists", () => {
+ let feed;
+ function createFeed() {
+ feed = { onAction: sinon.spy() };
+ return feed;
+ }
+ const action = { type: "BAR" };
+ store._feedFactories = new Map([["foo", createFeed]]);
+ store.initFeed("foo");
+
+ store.uninitFeed("foo", action);
+
+ assert.calledOnce(feed.onAction);
+ assert.calledWith(feed.onAction, action);
+ });
+ it("should remove the feed from .feeds", () => {
+ class Foo {}
+ store._feedFactories = new Map([["foo", () => new Foo()]]);
+
+ store.initFeed("foo");
+ store.uninitFeed("foo");
+
+ assert.isFalse(store.feeds.has("foo"), "foo is not in .feeds");
+ });
+ });
+ describe("onPrefChanged", () => {
+ beforeEach(() => {
+ sinon.stub(store, "initFeed");
+ sinon.stub(store, "uninitFeed");
+ store._prefs.set("foo", false);
+ store.init(new Map([["foo", () => ({})]]));
+ });
+ it("should initialize the feed if called with true", () => {
+ store.onPrefChanged("foo", true);
+
+ assert.calledWith(store.initFeed, "foo");
+ assert.notCalled(store.uninitFeed);
+ });
+ it("should uninitialize the feed if called with false", () => {
+ store.onPrefChanged("foo", false);
+
+ assert.calledWith(store.uninitFeed, "foo");
+ assert.notCalled(store.initFeed);
+ });
+ it("should do nothing if not an expected feed", () => {
+ store.onPrefChanged("bar", false);
+
+ assert.notCalled(store.initFeed);
+ assert.notCalled(store.uninitFeed);
+ });
+ });
+ describe("#init", () => {
+ it("should call .initFeed with each key", async () => {
+ sinon.stub(store, "initFeed");
+ store._prefs.set("foo", true);
+ store._prefs.set("bar", true);
+ await store.init(
+ new Map([
+ ["foo", () => {}],
+ ["bar", () => {}],
+ ])
+ );
+ assert.calledWith(store.initFeed, "foo");
+ assert.calledWith(store.initFeed, "bar");
+ });
+ it("should call _initIndexedDB", async () => {
+ await store.init(new Map());
+
+ assert.calledOnce(store._initIndexedDB);
+ assert.calledWithExactly(store._initIndexedDB, "feeds.telemetry");
+ });
+ it("should access the db property of indexedDB", async () => {
+ store._initIndexedDB.restore();
+ await store.init(new Map());
+
+ assert.calledOnce(dbStub);
+ });
+ it("should reset ActivityStreamStorage telemetry if opening the db fails", async () => {
+ store._initIndexedDB.restore();
+ // Force an IndexedDB error
+ dbStub.rejects();
+
+ await store.init(new Map());
+
+ assert.calledOnce(dbStub);
+ assert.isNull(store.dbStorage.telemetry);
+ });
+ it("should not initialize the feed if the Pref is set to false", async () => {
+ sinon.stub(store, "initFeed");
+ store._prefs.set("foo", false);
+ await store.init(new Map([["foo", () => {}]]));
+ assert.notCalled(store.initFeed);
+ });
+ it("should observe the pref branch", async () => {
+ sinon.stub(store._prefs, "observeBranch");
+ await store.init(new Map());
+ assert.calledOnce(store._prefs.observeBranch);
+ assert.calledWith(store._prefs.observeBranch, store);
+ });
+ it("should initialize the ActivityStreamMessageChannel channel", async () => {
+ await store.init(new Map());
+ });
+ it("should emit an initial event if provided", async () => {
+ sinon.stub(store, "dispatch");
+ const action = { type: "FOO" };
+
+ await store.init(new Map(), action);
+
+ assert.calledOnce(store.dispatch);
+ assert.calledWith(store.dispatch, action);
+ });
+ it("should initialize the telemtry feed first", () => {
+ store._prefs.set("feeds.foo", true);
+ store._prefs.set("feeds.telemetry", true);
+ const telemetrySpy = sandbox.stub().returns({});
+ const fooSpy = sandbox.stub().returns({});
+ // Intentionally put the telemetry feed as the second item.
+ const feedFactories = new Map([
+ ["feeds.foo", fooSpy],
+ ["feeds.telemetry", telemetrySpy],
+ ]);
+ store.init(feedFactories);
+ assert.ok(telemetrySpy.calledBefore(fooSpy));
+ });
+ it("should dispatch init/load events", async () => {
+ await store.init(new Map(), { type: "FOO" });
+
+ assert.calledOnce(
+ store.getMessageChannel().simulateMessagesForExistingTabs
+ );
+ });
+ it("should dispatch INIT before LOAD", async () => {
+ const init = { type: "INIT" };
+ const load = { type: "TAB_LOAD" };
+ sandbox.stub(store, "dispatch");
+ store
+ .getMessageChannel()
+ .simulateMessagesForExistingTabs.callsFake(() => store.dispatch(load));
+ await store.init(new Map(), init);
+
+ assert.calledTwice(store.dispatch);
+ assert.equal(store.dispatch.firstCall.args[0], init);
+ assert.equal(store.dispatch.secondCall.args[0], load);
+ });
+ });
+ describe("#uninit", () => {
+ it("should emit an uninit event if provided on init", () => {
+ sinon.stub(store, "dispatch");
+ const action = { type: "BAR" };
+ store.init(new Map(), null, action);
+
+ store.uninit();
+
+ assert.calledOnce(store.dispatch);
+ assert.calledWith(store.dispatch, action);
+ });
+ it("should clear .feeds and ._feedFactories", () => {
+ store._prefs.set("a", true);
+ store.init(
+ new Map([
+ ["a", () => ({})],
+ ["b", () => ({})],
+ ["c", () => ({})],
+ ])
+ );
+
+ store.uninit();
+
+ assert.equal(store.feeds.size, 0);
+ assert.isNull(store._feedFactories);
+ });
+ });
+ describe("#getState", () => {
+ it("should return the redux state", () => {
+ store._store = createStore((prevState = 123) => prevState);
+ const { getState } = store;
+ assert.equal(getState(), 123);
+ });
+ });
+ describe("#dispatch", () => {
+ it("should call .onAction of each feed", async () => {
+ const { dispatch } = store;
+ const sub = { onAction: sinon.spy() };
+ const action = { type: "FOO" };
+
+ store._prefs.set("sub", true);
+ await store.init(new Map([["sub", () => sub]]));
+
+ dispatch(action);
+
+ assert.calledWith(sub.onAction, action);
+ });
+ it("should call the reducers", () => {
+ const { dispatch } = store;
+ store._store = createStore(addNumberReducer);
+
+ dispatch({ type: "ADD", data: 14 });
+
+ assert.equal(store.getState(), 14);
+ });
+ });
+ describe("#subscribe", () => {
+ it("should subscribe to changes to the store", () => {
+ const sub = sinon.spy();
+ const action = { type: "FOO" };
+
+ store.subscribe(sub);
+ store.dispatch(action);
+
+ assert.calledOnce(sub);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js
new file mode 100644
index 0000000000..4dd5febdb2
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js
@@ -0,0 +1,76 @@
+import { SYSTEM_TICK_INTERVAL, SystemTickFeed } from "lib/SystemTickFeed.jsm";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("System Tick Feed", () => {
+ let globals;
+ let instance;
+ let clock;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ clock = sinon.useFakeTimers();
+
+ instance = new SystemTickFeed();
+ instance.store = {
+ getState() {
+ return {};
+ },
+ dispatch() {},
+ };
+ });
+ afterEach(() => {
+ globals.restore();
+ clock.restore();
+ });
+ it("should create a SystemTickFeed", () => {
+ assert.instanceOf(instance, SystemTickFeed);
+ });
+ it("should fire SYSTEM_TICK events at configured interval", () => {
+ globals.set("ChromeUtils", {
+ idleDispatch: f => f(),
+ });
+ let expectation = sinon
+ .mock(instance.store)
+ .expects("dispatch")
+ .twice()
+ .withExactArgs({ type: at.SYSTEM_TICK });
+
+ instance.onAction({ type: at.INIT });
+ clock.tick(SYSTEM_TICK_INTERVAL * 2);
+ expectation.verify();
+ });
+ it("should not fire SYSTEM_TICK events after UNINIT", () => {
+ let expectation = sinon.mock(instance.store).expects("dispatch").never();
+
+ instance.onAction({ type: at.UNINIT });
+ clock.tick(SYSTEM_TICK_INTERVAL * 2);
+ expectation.verify();
+ });
+ it("should not fire SYSTEM_TICK events while the user is away", () => {
+ let expectation = sinon.mock(instance.store).expects("dispatch").never();
+
+ instance.onAction({ type: at.INIT });
+ instance._idleService = { idleTime: SYSTEM_TICK_INTERVAL + 1 };
+ clock.tick(SYSTEM_TICK_INTERVAL * 3);
+ expectation.verify();
+ instance.onAction({ type: at.UNINIT });
+ });
+ it("should fire SYSTEM_TICK immediately when the user is active again", () => {
+ globals.set("ChromeUtils", {
+ idleDispatch: f => f(),
+ });
+ let expectation = sinon
+ .mock(instance.store)
+ .expects("dispatch")
+ .once()
+ .withExactArgs({ type: at.SYSTEM_TICK });
+
+ instance.onAction({ type: at.INIT });
+ instance._idleService = { idleTime: SYSTEM_TICK_INTERVAL + 1 };
+ clock.tick(SYSTEM_TICK_INTERVAL * 3);
+ instance.observe();
+ expectation.verify();
+ instance.onAction({ type: at.UNINIT });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js b/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
new file mode 100644
index 0000000000..1606f98e94
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
@@ -0,0 +1,2606 @@
+/* global Services */
+import {
+ actionCreators as ac,
+ actionTypes as at,
+ actionUtils as au,
+} from "common/Actions.sys.mjs";
+import {
+ ASRouterEventPing,
+ BasePing,
+ ImpressionStatsPing,
+ SessionPing,
+ UserEventPing,
+} from "test/schemas/pings";
+import { FAKE_GLOBAL_PREFS, GlobalOverrider } from "test/unit/utils";
+import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm";
+import injector from "inject!lib/TelemetryFeed.jsm";
+import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs";
+
+const FAKE_UUID = "{foo-123-foo}";
+const FAKE_ROUTER_MESSAGE_PROVIDER = [{ id: "cfr", enabled: true }];
+const FAKE_TELEMETRY_ID = "foo123";
+
+// eslint-disable-next-line max-statements
+describe("TelemetryFeed", () => {
+ let globals;
+ let sandbox;
+ let expectedUserPrefs;
+ let browser = {
+ getAttribute() {
+ return "true";
+ },
+ };
+ let instance;
+ let clock;
+ let fakeHomePageUrl;
+ let fakeHomePage;
+ let fakeExtensionSettingsStore;
+ let ExperimentAPI = { getExperimentMetaData: () => {} };
+ class PingCentre {
+ sendPing() {}
+ uninit() {}
+ sendStructuredIngestionPing() {}
+ }
+ class UTEventReporting {
+ sendUserEvent() {}
+ sendSessionEndEvent() {}
+ uninit() {}
+ }
+
+ // Reset the global prefs before importing the `TelemetryFeed` module, to
+ // avoid a coverage miss caused by preference pollution when this test and
+ // `ActivityStream.test.js` are run together.
+ //
+ // The `TelemetryFeed` module defines a lazy `contextId` getter, which the
+ // `XPCOMUtils.defineLazyGetter` mock (defined in `unit-entry.js`) executes
+ // immediately, as soon as the module is imported.
+ //
+ // If this test runs first, there's no coverage miss: this test will load
+ // the `TelemetryFeed` module and run the lazy `contextId` getter, which will
+ // generate a fake context ID and store it in `FAKE_GLOBAL_PREFS`, covering
+ // all branches in the module. When `ActivityStream.test.js` runs, it'll load
+ // `TelemetryFeed` and run the lazy getter a second time, which will use the
+ // existing fake context ID from `FAKE_GLOBAL_PREFS` instead of generating a
+ // new one.
+ //
+ // But, if `ActivityStream.test.js` runs first, then loading `TelemetryFeed` a
+ // second time as part of this test will use the existing fake context ID from
+ // `FAKE_GLOBAL_PREFS`, missing coverage for the branch to generate a new
+ // context ID.
+ FAKE_GLOBAL_PREFS.clear();
+
+ const {
+ TelemetryFeed,
+ USER_PREFS_ENCODING,
+ PREF_IMPRESSION_ID,
+ TELEMETRY_PREF,
+ EVENTS_TELEMETRY_PREF,
+ STRUCTURED_INGESTION_ENDPOINT_PREF,
+ } = injector({
+ "lib/UTEventReporting.sys.mjs": { UTEventReporting },
+ });
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ clock = sinon.useFakeTimers();
+ fakeHomePageUrl = "about:home";
+ fakeHomePage = {
+ get() {
+ return fakeHomePageUrl;
+ },
+ };
+ fakeExtensionSettingsStore = {
+ initialize() {
+ return Promise.resolve();
+ },
+ getSetting() {},
+ };
+ sandbox.spy(global.console, "error");
+ globals.set("AboutNewTab", {
+ newTabURLOverridden: false,
+ newTabURL: "",
+ });
+ globals.set("pktApi", {
+ isUserLoggedIn: () => true,
+ });
+ globals.set("HomePage", fakeHomePage);
+ globals.set("ExtensionSettingsStore", fakeExtensionSettingsStore);
+ globals.set("PingCentre", PingCentre);
+ globals.set("UTEventReporting", UTEventReporting);
+ globals.set("ClientID", {
+ getClientID: sandbox.spy(async () => FAKE_TELEMETRY_ID),
+ });
+ globals.set("ExperimentAPI", ExperimentAPI);
+
+ sandbox
+ .stub(ASRouterPreferences, "providers")
+ .get(() => FAKE_ROUTER_MESSAGE_PROVIDER);
+ instance = new TelemetryFeed();
+ });
+ afterEach(() => {
+ clock.restore();
+ globals.restore();
+ FAKE_GLOBAL_PREFS.clear();
+ ASRouterPreferences.uninit();
+ });
+ describe("#init", () => {
+ it("should create an instance", () => {
+ const testInstance = new TelemetryFeed();
+ assert.isDefined(testInstance);
+ });
+ it("should add .pingCentre, a PingCentre instance", () => {
+ assert.instanceOf(instance.pingCentre, PingCentre);
+ });
+ it("should add .utEvents, a UTEventReporting instance", () => {
+ assert.instanceOf(instance.utEvents, UTEventReporting);
+ });
+ it("should make this.browserOpenNewtabStart() observe browser-open-newtab-start", () => {
+ sandbox.spy(Services.obs, "addObserver");
+
+ instance.init();
+
+ assert.calledTwice(Services.obs.addObserver);
+ assert.calledWithExactly(
+ Services.obs.addObserver,
+ instance.browserOpenNewtabStart,
+ "browser-open-newtab-start"
+ );
+ });
+ it("should add window open listener", () => {
+ sandbox.spy(Services.obs, "addObserver");
+
+ instance.init();
+
+ assert.calledTwice(Services.obs.addObserver);
+ assert.calledWithExactly(
+ Services.obs.addObserver,
+ instance._addWindowListeners,
+ "domwindowopened"
+ );
+ });
+ it("should add TabPinned event listener on new windows", () => {
+ const stub = { addEventListener: sandbox.stub() };
+ sandbox.spy(Services.obs, "addObserver");
+
+ instance.init();
+
+ assert.calledTwice(Services.obs.addObserver);
+ const [cb] = Services.obs.addObserver.secondCall.args;
+ cb(stub);
+ assert.calledTwice(stub.addEventListener);
+ assert.calledWithExactly(
+ stub.addEventListener,
+ "unload",
+ instance.handleEvent
+ );
+ assert.calledWithExactly(
+ stub.addEventListener,
+ "TabPinned",
+ instance.handleEvent
+ );
+ });
+ it("should create impression id if none exists", () => {
+ assert.equal(instance._impressionId, FAKE_UUID);
+ });
+ it("should set impression id if it exists", () => {
+ FAKE_GLOBAL_PREFS.set(PREF_IMPRESSION_ID, "fakeImpressionId");
+ assert.equal(new TelemetryFeed()._impressionId, "fakeImpressionId");
+ });
+ it("should register listeners on existing windows", () => {
+ const stub = sandbox.stub();
+ globals.set({
+ Services: {
+ ...Services,
+ wm: { getEnumerator: () => [{ addEventListener: stub }] },
+ },
+ });
+
+ instance.init();
+
+ assert.calledTwice(stub);
+ assert.calledWithExactly(stub, "unload", instance.handleEvent);
+ assert.calledWithExactly(stub, "TabPinned", instance.handleEvent);
+ });
+ describe("telemetry pref changes from false to true", () => {
+ beforeEach(() => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, false);
+ instance = new TelemetryFeed();
+
+ assert.propertyVal(instance, "telemetryEnabled", false);
+ });
+
+ it("should set the enabled property to true", () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+
+ assert.propertyVal(instance, "telemetryEnabled", true);
+ });
+ });
+ describe("events telemetry pref changes from false to true", () => {
+ beforeEach(() => {
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, false);
+ instance = new TelemetryFeed();
+
+ assert.propertyVal(instance, "eventTelemetryEnabled", false);
+ });
+
+ it("should set the enabled property to true", () => {
+ instance._prefs.set(EVENTS_TELEMETRY_PREF, true);
+
+ assert.propertyVal(instance, "eventTelemetryEnabled", true);
+ });
+ });
+ it("should set two scalars for deletion-request", () => {
+ sandbox.spy(Services.telemetry, "scalarSet");
+
+ instance.init();
+
+ assert.calledTwice(Services.telemetry.scalarSet);
+
+ // impression_id
+ let [type, value] = Services.telemetry.scalarSet.firstCall.args;
+ assert.equal(type, "deletion.request.impression_id");
+ assert.equal(value, instance._impressionId);
+
+ // context_id
+ [type, value] = Services.telemetry.scalarSet.secondCall.args;
+ assert.equal(type, "deletion.request.context_id");
+ assert.equal(value, FAKE_UUID);
+ });
+ describe("#_beginObservingNewtabPingPrefs", () => {
+ it("should record initial metrics from newtab prefs", () => {
+ FAKE_GLOBAL_PREFS.set(
+ "browser.newtabpage.activity-stream.feeds.topsites",
+ true
+ );
+ FAKE_GLOBAL_PREFS.set(
+ "browser.newtabpage.activity-stream.topSitesRows",
+ 3
+ );
+ FAKE_GLOBAL_PREFS.set(
+ "browser.topsites.blockedSponsors",
+ '["mozilla"]'
+ );
+
+ sandbox.spy(Glean.topsites.enabled, "set");
+ sandbox.spy(Glean.topsites.rows, "set");
+ sandbox.spy(Glean.newtab.blockedSponsors, "set");
+
+ instance = new TelemetryFeed();
+ instance.init();
+
+ assert.calledOnce(Glean.topsites.enabled.set);
+ assert.calledWith(Glean.topsites.enabled.set, true);
+ assert.calledOnce(Glean.topsites.rows.set);
+ assert.calledWith(Glean.topsites.rows.set, 3);
+ assert.calledOnce(Glean.newtab.blockedSponsors.set);
+ assert.calledWith(Glean.newtab.blockedSponsors.set, ["mozilla"]);
+ });
+
+ it("should not record blocked sponsor metrics when bad json string is passed", () => {
+ FAKE_GLOBAL_PREFS.set("browser.topsites.blockedSponsors", "BAD[JSON]");
+
+ sandbox.spy(Glean.newtab.blockedSponsors, "set");
+
+ instance = new TelemetryFeed();
+ instance.init();
+
+ assert.notCalled(Glean.newtab.blockedSponsors.set);
+ });
+
+ it("should record new metrics for newtab pref changes", () => {
+ FAKE_GLOBAL_PREFS.set(
+ "browser.newtabpage.activity-stream.topSitesRows",
+ 3
+ );
+ FAKE_GLOBAL_PREFS.set("browser.topsites.blockedSponsors", "[]");
+ sandbox.spy(Glean.topsites.rows, "set");
+ sandbox.spy(Glean.newtab.blockedSponsors, "set");
+
+ instance = new TelemetryFeed();
+ instance.init();
+
+ Services.prefs.setIntPref(
+ "browser.newtabpage.activity-stream.topSitesRows",
+ 2
+ );
+
+ Services.prefs.setStringPref(
+ "browser.topsites.blockedSponsors",
+ '["mozilla"]'
+ );
+
+ assert.calledTwice(Glean.topsites.rows.set);
+ assert.calledWith(Glean.topsites.rows.set.firstCall, 3);
+ assert.calledWith(Glean.topsites.rows.set.secondCall, 2);
+ assert.calledWith(Glean.newtab.blockedSponsors.set.firstCall, []);
+ assert.calledWith(Glean.newtab.blockedSponsors.set.secondCall, [
+ "mozilla",
+ ]);
+ });
+ it("should ignore changes to other prefs", () => {
+ FAKE_GLOBAL_PREFS.set("some.other.pref", 123);
+ FAKE_GLOBAL_PREFS.set(
+ "browser.newtabpage.activity-stream.impressionId",
+ "{foo-123-foo}"
+ );
+
+ instance = new TelemetryFeed();
+ instance.init();
+
+ Services.prefs.setIntPref("some.other.pref", 456);
+ Services.prefs.setCharPref(
+ "browser.newtabpage.activity-stream.impressionId",
+ "{foo-456-foo}"
+ );
+ });
+ });
+ });
+ describe("#handleEvent", () => {
+ it("should dispatch a TAB_PINNED_EVENT", () => {
+ sandbox.stub(instance, "sendEvent");
+ globals.set({
+ Services: {
+ ...Services,
+ wm: {
+ getEnumerator: () => [{ gBrowser: { tabs: [{ pinned: true }] } }],
+ },
+ },
+ });
+
+ instance.handleEvent({ type: "TabPinned", target: {} });
+
+ assert.calledOnce(instance.sendEvent);
+ const [ping] = instance.sendEvent.firstCall.args;
+ assert.propertyVal(ping, "event", "TABPINNED");
+ assert.propertyVal(ping, "source", "TAB_CONTEXT_MENU");
+ assert.propertyVal(ping, "session_id", "n/a");
+ assert.propertyVal(ping.value, "total_pinned_tabs", 1);
+ });
+ it("should skip private windows", () => {
+ sandbox.stub(instance, "sendEvent");
+ globals.set({ PrivateBrowsingUtils: { isWindowPrivate: () => true } });
+
+ instance.handleEvent({ type: "TabPinned", target: {} });
+
+ assert.notCalled(instance.sendEvent);
+ });
+ it("should return the correct value for total_pinned_tabs", () => {
+ sandbox.stub(instance, "sendEvent");
+ globals.set({
+ Services: {
+ ...Services,
+ wm: {
+ getEnumerator: () => [
+ {
+ gBrowser: { tabs: [{ pinned: true }, { pinned: false }] },
+ },
+ ],
+ },
+ },
+ });
+
+ instance.handleEvent({ type: "TabPinned", target: {} });
+
+ assert.calledOnce(instance.sendEvent);
+ const [ping] = instance.sendEvent.firstCall.args;
+ assert.propertyVal(ping, "event", "TABPINNED");
+ assert.propertyVal(ping, "source", "TAB_CONTEXT_MENU");
+ assert.propertyVal(ping, "session_id", "n/a");
+ assert.propertyVal(ping.value, "total_pinned_tabs", 1);
+ });
+ it("should return the correct value for total_pinned_tabs (when private windows are open)", () => {
+ sandbox.stub(instance, "sendEvent");
+ const privateWinStub = sandbox
+ .stub()
+ .onCall(0)
+ .returns(false)
+ .onCall(1)
+ .returns(true);
+ globals.set({
+ PrivateBrowsingUtils: { isWindowPrivate: privateWinStub },
+ });
+ globals.set({
+ Services: {
+ ...Services,
+ wm: {
+ getEnumerator: () => [
+ {
+ gBrowser: { tabs: [{ pinned: true }, { pinned: true }] },
+ },
+ ],
+ },
+ },
+ });
+
+ instance.handleEvent({ type: "TabPinned", target: {} });
+
+ assert.calledOnce(instance.sendEvent);
+ const [ping] = instance.sendEvent.firstCall.args;
+ assert.propertyVal(ping.value, "total_pinned_tabs", 0);
+ });
+ it("should unregister the event listeners", () => {
+ const stub = { removeEventListener: sandbox.stub() };
+
+ instance.handleEvent({ type: "unload", target: stub });
+
+ assert.calledTwice(stub.removeEventListener);
+ assert.calledWithExactly(
+ stub.removeEventListener,
+ "unload",
+ instance.handleEvent
+ );
+ assert.calledWithExactly(
+ stub.removeEventListener,
+ "TabPinned",
+ instance.handleEvent
+ );
+ });
+ });
+ describe("#addSession", () => {
+ it("should add a session and return it", () => {
+ const session = instance.addSession("foo");
+
+ assert.equal(instance.sessions.get("foo"), session);
+ });
+ it("should set the session_id", () => {
+ sandbox.spy(Services.uuid, "generateUUID");
+
+ const session = instance.addSession("foo");
+
+ assert.calledOnce(Services.uuid.generateUUID);
+ assert.equal(
+ session.session_id,
+ Services.uuid.generateUUID.firstCall.returnValue
+ );
+ });
+ it("should set the page if a url parameter is given", () => {
+ const session = instance.addSession("foo", "about:monkeys");
+
+ assert.propertyVal(session, "page", "about:monkeys");
+ });
+ it("should set the page prop to 'unknown' if no URL parameter given", () => {
+ const session = instance.addSession("foo");
+
+ assert.propertyVal(session, "page", "unknown");
+ });
+ it("should set the perf type when lacking timestamp", () => {
+ const session = instance.addSession("foo");
+
+ assert.propertyVal(session.perf, "load_trigger_type", "unexpected");
+ });
+ it("should set load_trigger_type to first_window_opened on the first about:home seen", () => {
+ const session = instance.addSession("foo", "about:home");
+
+ assert.propertyVal(
+ session.perf,
+ "load_trigger_type",
+ "first_window_opened"
+ );
+ });
+ it("should not set load_trigger_type to first_window_opened on the second about:home seen", () => {
+ instance.addSession("foo", "about:home");
+
+ const session2 = instance.addSession("foo", "about:home");
+
+ assert.notPropertyVal(
+ session2.perf,
+ "load_trigger_type",
+ "first_window_opened"
+ );
+ });
+ it("should set load_trigger_ts to the value of the process start timestamp", () => {
+ const session = instance.addSession("foo", "about:home");
+
+ assert.propertyVal(session.perf, "load_trigger_ts", 1588010448000);
+ });
+ it("should create a valid session ping on the first about:home seen", () => {
+ // Add a session
+ const portID = "foo";
+ const session = instance.addSession(portID, "about:home");
+
+ // Create a ping referencing the session
+ const ping = instance.createSessionEndEvent(session);
+ assert.validate(ping, SessionPing);
+ });
+ it("should be a valid ping with the data_late_by_ms perf", () => {
+ // Add a session
+ const portID = "foo";
+ const session = instance.addSession(portID, "about:home");
+ instance.saveSessionPerfData("foo", { topsites_data_late_by_ms: 10 });
+ instance.saveSessionPerfData("foo", { highlights_data_late_by_ms: 20 });
+
+ // Create a ping referencing the session
+ const ping = instance.createSessionEndEvent(session);
+ assert.validate(ping, SessionPing);
+ assert.propertyVal(
+ instance.sessions.get("foo").perf,
+ "highlights_data_late_by_ms",
+ 20
+ );
+ assert.propertyVal(
+ instance.sessions.get("foo").perf,
+ "topsites_data_late_by_ms",
+ 10
+ );
+ });
+ it("should be a valid ping with the topsites stats perf", () => {
+ // Add a session
+ const portID = "foo";
+ const session = instance.addSession(portID, "about:home");
+ instance.saveSessionPerfData("foo", {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot_with_icon: 2,
+ screenshot: 1,
+ tippytop: 2,
+ rich_icon: 1,
+ no_image: 0,
+ },
+ topsites_pinned: 3,
+ topsites_search_shortcuts: 2,
+ });
+
+ // Create a ping referencing the session
+ const ping = instance.createSessionEndEvent(session);
+ assert.validate(ping, SessionPing);
+ assert.propertyVal(
+ instance.sessions.get("foo").perf.topsites_icon_stats,
+ "screenshot_with_icon",
+ 2
+ );
+ assert.equal(instance.sessions.get("foo").perf.topsites_pinned, 3);
+ assert.equal(
+ instance.sessions.get("foo").perf.topsites_search_shortcuts,
+ 2
+ );
+ });
+ });
+
+ describe("#browserOpenNewtabStart", () => {
+ it("should call ChromeUtils.addProfilerMarker with browser-open-newtab-start", () => {
+ globals.set("ChromeUtils", {
+ addProfilerMarker: sandbox.stub(),
+ });
+
+ sandbox.stub(global.Cu, "now").returns(12345);
+
+ instance.browserOpenNewtabStart();
+
+ assert.calledOnce(ChromeUtils.addProfilerMarker);
+ assert.calledWithExactly(
+ ChromeUtils.addProfilerMarker,
+ "UserTiming",
+ 12345,
+ "browser-open-newtab-start"
+ );
+ });
+ });
+
+ describe("#endSession", () => {
+ it("should not throw if there is no session for the given port ID", () => {
+ assert.doesNotThrow(() => instance.endSession("doesn't exist"));
+ });
+ it("should add a session_duration integer if there is a visibility_event_rcvd_ts", () => {
+ sandbox.stub(instance, "sendEvent");
+ const session = instance.addSession("foo");
+ session.perf.visibility_event_rcvd_ts = 444.4732;
+
+ instance.endSession("foo");
+
+ assert.isNumber(session.session_duration);
+ assert.ok(
+ Number.isInteger(session.session_duration),
+ "session_duration should be an integer"
+ );
+ });
+ it("shouldn't send session ping if there's no visibility_event_rcvd_ts", () => {
+ sandbox.stub(instance, "sendEvent");
+ instance.addSession("foo");
+
+ instance.endSession("foo");
+
+ assert.notCalled(instance.sendEvent);
+ assert.isFalse(instance.sessions.has("foo"));
+ });
+ it("should remove the session from .sessions", () => {
+ sandbox.stub(instance, "sendEvent");
+ instance.addSession("foo");
+
+ instance.endSession("foo");
+
+ assert.isFalse(instance.sessions.has("foo"));
+ });
+ it("should call createSessionSendEvent and sendEvent with the sesssion", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ instance = new TelemetryFeed();
+
+ sandbox.stub(instance, "sendEvent");
+ sandbox.stub(instance, "createSessionEndEvent");
+ sandbox.stub(instance.utEvents, "sendSessionEndEvent");
+ const session = instance.addSession("foo");
+ session.perf.visibility_event_rcvd_ts = 444.4732;
+
+ instance.endSession("foo");
+
+ // Did we call sendEvent with the result of createSessionEndEvent?
+ assert.calledWith(instance.createSessionEndEvent, session);
+
+ let sessionEndEvent =
+ instance.createSessionEndEvent.firstCall.returnValue;
+ assert.calledWith(instance.sendEvent, sessionEndEvent);
+ assert.calledWith(instance.utEvents.sendSessionEndEvent, sessionEndEvent);
+ });
+ });
+ describe("ping creators", () => {
+ beforeEach(() => {
+ for (const pref of Object.keys(USER_PREFS_ENCODING)) {
+ FAKE_GLOBAL_PREFS.set(pref, true);
+ expectedUserPrefs |= USER_PREFS_ENCODING[pref];
+ }
+ instance.init();
+ });
+ describe("#createPing", () => {
+ it("should create a valid base ping without a session if no portID is supplied", async () => {
+ const ping = await instance.createPing();
+ assert.validate(ping, BasePing);
+ assert.notProperty(ping, "session_id");
+ assert.notProperty(ping, "page");
+ });
+ it("should create a valid base ping with session info if a portID is supplied", async () => {
+ // Add a session
+ const portID = "foo";
+ instance.addSession(portID, "about:home");
+ const sessionID = instance.sessions.get(portID).session_id;
+
+ // Create a ping referencing the session
+ const ping = await instance.createPing(portID);
+ assert.validate(ping, BasePing);
+
+ // Make sure we added the right session-related stuff to the ping
+ assert.propertyVal(ping, "session_id", sessionID);
+ assert.propertyVal(ping, "page", "about:home");
+ });
+ it("should create an unexpected base ping if no session yet portID is supplied", async () => {
+ const ping = await instance.createPing("foo");
+
+ assert.validate(ping, BasePing);
+ assert.propertyVal(ping, "page", "unknown");
+ assert.propertyVal(
+ instance.sessions.get("foo").perf,
+ "load_trigger_type",
+ "unexpected"
+ );
+ });
+ it("should create a base ping with user_prefs", async () => {
+ const ping = await instance.createPing("foo");
+
+ assert.validate(ping, BasePing);
+ assert.propertyVal(ping, "user_prefs", expectedUserPrefs);
+ });
+ });
+ describe("#createUserEvent", () => {
+ it("should create a valid event", async () => {
+ const portID = "foo";
+ const data = { source: "TOP_SITES", event: "CLICK" };
+ const action = ac.AlsoToMain(ac.UserEvent(data), portID);
+ const session = instance.addSession(portID);
+
+ const ping = await instance.createUserEvent(action);
+
+ // Is it valid?
+ assert.validate(ping, UserEventPing);
+ // Does it have the right session_id?
+ assert.propertyVal(ping, "session_id", session.session_id);
+ });
+ });
+ describe("#createSessionEndEvent", () => {
+ it("should create a valid event", async () => {
+ const ping = await instance.createSessionEndEvent({
+ session_id: FAKE_UUID,
+ page: "about:newtab",
+ session_duration: 12345,
+ perf: {
+ load_trigger_ts: 10,
+ load_trigger_type: "menu_plus_or_keyboard",
+ visibility_event_rcvd_ts: 20,
+ is_preloaded: true,
+ },
+ });
+
+ // Is it valid?
+ assert.validate(ping, SessionPing);
+ assert.propertyVal(ping, "session_id", FAKE_UUID);
+ assert.propertyVal(ping, "page", "about:newtab");
+ assert.propertyVal(ping, "session_duration", 12345);
+ });
+ it("should create a valid unexpected session event", async () => {
+ const ping = await instance.createSessionEndEvent({
+ session_id: FAKE_UUID,
+ page: "about:newtab",
+ session_duration: 12345,
+ perf: {
+ load_trigger_type: "unexpected",
+ is_preloaded: true,
+ },
+ });
+
+ // Is it valid?
+ assert.validate(ping, SessionPing);
+ assert.propertyVal(ping, "session_id", FAKE_UUID);
+ assert.propertyVal(ping, "page", "about:newtab");
+ assert.propertyVal(ping, "session_duration", 12345);
+ assert.propertyVal(ping.perf, "load_trigger_type", "unexpected");
+ });
+ });
+ });
+ describe("#createImpressionStats", () => {
+ it("should create a valid impression stats ping", async () => {
+ const tiles = [{ id: 10001 }, { id: 10002 }, { id: 10003 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.propertyVal(ping, "source", "POCKET");
+ assert.propertyVal(ping, "tiles", tiles);
+ });
+ it("should create a valid click ping", async () => {
+ const tiles = [{ id: 10001, pos: 2 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles, click: 0 });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.propertyVal(ping, "click", 0);
+ assert.propertyVal(ping, "tiles", tiles);
+ });
+ it("should create a valid block ping", async () => {
+ const tiles = [{ id: 10001, pos: 2 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles, block: 0 });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.propertyVal(ping, "block", 0);
+ assert.propertyVal(ping, "tiles", tiles);
+ });
+ it("should create a valid pocket ping", async () => {
+ const tiles = [{ id: 10001, pos: 2 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles, pocket: 0 });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.propertyVal(ping, "pocket", 0);
+ assert.propertyVal(ping, "tiles", tiles);
+ });
+ it("should pass shim if it is available to impression ping", async () => {
+ const tiles = [{ id: 10001, pos: 2, shim: 1234 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.propertyVal(ping, "tiles", tiles);
+ assert.propertyVal(ping.tiles[0], "shim", tiles[0].shim);
+ });
+ it("should not include client_id and session_id", async () => {
+ const tiles = [{ id: 10001 }, { id: 10002 }, { id: 10003 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.notProperty(ping, "client_id");
+ assert.notProperty(ping, "session_id");
+ });
+ });
+ describe("#applyCFRPolicy", () => {
+ it("should use client_id and message_id in prerelease", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "nightly";
+ },
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ assert.propertyVal(ping, "message_id", "cfr_message_01");
+ });
+ it("should use impression_id and bucket_id in release", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.client_id);
+ assert.propertyVal(ping, "impression_id", FAKE_UUID);
+ assert.propertyVal(ping, "message_id", "n/a");
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ });
+ it("should use client_id and message_id in the experiment cohort in release", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({
+ slug: "SOME-CFR-EXP",
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ assert.propertyVal(ping, "message_id", "cfr_message_01");
+ });
+ it("should use impression_id and bucket_id in Private Browsing", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ is_private: true,
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.client_id);
+ assert.propertyVal(ping, "impression_id", FAKE_UUID);
+ assert.propertyVal(ping, "message_id", "n/a");
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ });
+ it("should use client_id and message_id in the experiment cohort in Private Browsing", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({
+ slug: "SOME-CFR-EXP",
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ is_private: true,
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ assert.propertyVal(ping, "message_id", "cfr_message_01");
+ });
+ });
+ describe("#applyWhatsNewPolicy", () => {
+ it("should set client_id and set pingType", async () => {
+ const { ping, pingType } = await instance.applyWhatsNewPolicy({});
+
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.equal(pingType, "whats-new-panel");
+ });
+ });
+ describe("#applyInfoBarPolicy", () => {
+ it("should set client_id and set pingType", async () => {
+ const { ping, pingType } = await instance.applyInfoBarPolicy({});
+
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.equal(pingType, "infobar");
+ });
+ });
+ describe("#applyToastNotificationPolicy", () => {
+ it("should set client_id and set pingType", async () => {
+ const { ping, pingType } = await instance.applyToastNotificationPolicy(
+ {}
+ );
+
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.equal(pingType, "toast_notification");
+ });
+ });
+ describe("#applySpotlightPolicy", () => {
+ it("should set client_id and set pingType", async () => {
+ let pingData = { action: "foo" };
+ const { ping, pingType } = await instance.applySpotlightPolicy(pingData);
+
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.equal(pingType, "spotlight");
+ assert.notProperty(ping, "action");
+ });
+ });
+ describe("#applyMomentsPolicy", () => {
+ it("should use client_id and message_id in prerelease", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "nightly";
+ },
+ });
+ const data = {
+ action: "moments_user_event",
+ event: "IMPRESSION",
+ message_id: "moments_message_01",
+ bucket_id: "moments_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyMomentsPolicy(data);
+
+ assert.equal(pingType, "moments");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "moments_bucket_01");
+ assert.propertyVal(ping, "message_id", "moments_message_01");
+ });
+ it("should use impression_id and bucket_id in release", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ const data = {
+ action: "moments_user_event",
+ event: "IMPRESSION",
+ message_id: "moments_message_01",
+ bucket_id: "moments_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyMomentsPolicy(data);
+
+ assert.equal(pingType, "moments");
+ assert.isUndefined(ping.client_id);
+ assert.propertyVal(ping, "impression_id", FAKE_UUID);
+ assert.propertyVal(ping, "message_id", "n/a");
+ assert.propertyVal(ping, "bucket_id", "moments_bucket_01");
+ });
+ it("should use client_id and message_id in the experiment cohort in release", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({
+ slug: "SOME-CFR-EXP",
+ });
+ const data = {
+ action: "moments_user_event",
+ event: "IMPRESSION",
+ message_id: "moments_message_01",
+ bucket_id: "moments_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyMomentsPolicy(data);
+
+ assert.equal(pingType, "moments");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "moments_bucket_01");
+ assert.propertyVal(ping, "message_id", "moments_message_01");
+ });
+ });
+ describe("#applySnippetsPolicy", () => {
+ it("should include client_id", async () => {
+ const data = {
+ action: "snippets_user_event",
+ event: "IMPRESSION",
+ message_id: "snippets_message_01",
+ };
+ const { ping, pingType } = await instance.applySnippetsPolicy(data);
+
+ assert.equal(pingType, "snippets");
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "message_id", "snippets_message_01");
+ });
+ });
+ describe("#applyOnboardingPolicy", () => {
+ it("should include client_id", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(data);
+
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ assert.propertyVal(ping, "browser_session_id", "fake_session_id");
+ });
+ it("should include page to event_context if there is a session", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ };
+ const session = { page: "about:welcome" };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(
+ data,
+ session
+ );
+
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(
+ ping,
+ "event_context",
+ JSON.stringify({ page: "about:welcome" })
+ );
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ });
+ it("should not set page if it is not in ONBOARDING_ALLOWED_PAGE_VALUES", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ };
+ const session = { page: "foo" };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(
+ data,
+ session
+ );
+
+ assert.calledOnce(global.console.error);
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(ping, "event_context", JSON.stringify({}));
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ });
+ it("should append page to event_context if it is not empty", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ event_context: JSON.stringify({ foo: "bar" }),
+ };
+ const session = { page: "about:welcome" };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(
+ data,
+ session
+ );
+
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(
+ ping,
+ "event_context",
+ JSON.stringify({ foo: "bar", page: "about:welcome" })
+ );
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ });
+ it("should append page to event_context if it is not a JSON serialized string", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ event_context: "foo",
+ };
+ const session = { page: "about:welcome" };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(
+ data,
+ session
+ );
+
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(
+ ping,
+ "event_context",
+ JSON.stringify({ value: "foo", page: "about:welcome" })
+ );
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ });
+ });
+ describe("#applyUndesiredEventPolicy", () => {
+ it("should exclude client_id and use impression_id", () => {
+ const data = {
+ action: "asrouter_undesired_event",
+ event: "RS_MISSING_DATA",
+ };
+ const { ping, pingType } = instance.applyUndesiredEventPolicy(data);
+
+ assert.equal(pingType, "undesired-events");
+ assert.isUndefined(ping.client_id);
+ assert.propertyVal(ping, "impression_id", FAKE_UUID);
+ });
+ });
+ describe("#createASRouterEvent", () => {
+ it("should create a valid AS Router event", async () => {
+ const data = {
+ action: "snippets_user_event",
+ event: "CLICK",
+ message_id: "snippets_message_01",
+ };
+ const action = ac.ASRouterUserEvent(data);
+ const { ping } = await instance.createASRouterEvent(action);
+
+ assert.validate(ping, ASRouterEventPing);
+ assert.propertyVal(ping, "event", "CLICK");
+ });
+ it("should call applyCFRPolicy if action equals to cfr_user_event", async () => {
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ message_id: "cfr_message_01",
+ };
+ sandbox.stub(instance, "applyCFRPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyCFRPolicy);
+ });
+ it("should call applySnippetsPolicy if action equals to snippets_user_event", async () => {
+ const data = {
+ action: "snippets_user_event",
+ event: "IMPRESSION",
+ message_id: "snippets_message_01",
+ };
+ sandbox.stub(instance, "applySnippetsPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applySnippetsPolicy);
+ });
+ it("should call applySnippetsPolicy if action equals to snippets_local_testing_user_event", async () => {
+ const data = {
+ action: "snippets_local_testing_user_event",
+ event: "IMPRESSION",
+ message_id: "snippets_message_01",
+ };
+ sandbox.stub(instance, "applySnippetsPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applySnippetsPolicy);
+ });
+ it("should call applyOnboardingPolicy if action equals to onboarding_user_event", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTON",
+ message_id: "onboarding_message_01",
+ };
+ sandbox.stub(instance, "applyOnboardingPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyOnboardingPolicy);
+ });
+ it("should call applyWhatsNewPolicy if action equals to whats-new-panel_user_event", async () => {
+ const data = {
+ action: "whats-new-panel_user_event",
+ event: "CLICK_BUTTON",
+ message_id: "whats-new-panel_message_01",
+ };
+ sandbox.stub(instance, "applyWhatsNewPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyWhatsNewPolicy);
+ });
+ it("should call applyMomentsPolicy if action equals to moments_user_event", async () => {
+ const data = {
+ action: "moments_user_event",
+ event: "CLICK_BUTTON",
+ message_id: "moments_message_01",
+ };
+ sandbox.stub(instance, "applyMomentsPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyMomentsPolicy);
+ });
+ it("should call applySpotlightPolicy if action equals to spotlight_user_event", async () => {
+ const data = {
+ action: "spotlight_user_event",
+ event: "CLICK",
+ message_id: "SPOTLIGHT_MESSAGE_93",
+ };
+ sandbox.stub(instance, "applySpotlightPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applySpotlightPolicy);
+ });
+ it("should call applyToastNotificationPolicy if action equals to toast_notification_user_event", async () => {
+ const data = {
+ action: "toast_notification_user_event",
+ event: "IMPRESSION",
+ message_id: "TEST_TOAST_NOTIFICATION1",
+ };
+ sandbox.stub(instance, "applyToastNotificationPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyToastNotificationPolicy);
+ });
+ it("should call applyUndesiredEventPolicy if action equals to asrouter_undesired_event", async () => {
+ const data = {
+ action: "asrouter_undesired_event",
+ event: "UNDESIRED_EVENT",
+ };
+ sandbox.stub(instance, "applyUndesiredEventPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyUndesiredEventPolicy);
+ });
+ it("should stringify event_context if it is an Object", async () => {
+ const data = {
+ action: "asrouter_undesired_event",
+ event: "UNDESIRED_EVENT",
+ event_context: { foo: "bar" },
+ };
+ const action = ac.ASRouterUserEvent(data);
+ const { ping } = await instance.createASRouterEvent(action);
+
+ assert.propertyVal(ping, "event_context", JSON.stringify({ foo: "bar" }));
+ });
+ it("should not stringify event_context if it is a String", async () => {
+ const data = {
+ action: "asrouter_undesired_event",
+ event: "UNDESIRED_EVENT",
+ event_context: "foo",
+ };
+ const action = ac.ASRouterUserEvent(data);
+ const { ping } = await instance.createASRouterEvent(action);
+
+ assert.propertyVal(ping, "event_context", "foo");
+ });
+ });
+ describe("#sendEventPing", () => {
+ it("should call sendStructuredIngestionEvent", async () => {
+ const data = {
+ action: "activity_stream_user_event",
+ event: "CLICK",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.sendEventPing(data);
+
+ const expectedPayload = {
+ client_id: FAKE_TELEMETRY_ID,
+ event: "CLICK",
+ browser_session_id: "fake_session_id",
+ };
+ assert.calledWith(instance.sendStructuredIngestionEvent, expectedPayload);
+ });
+ it("should stringify value if it is an Object", async () => {
+ const data = {
+ action: "activity_stream_user_event",
+ event: "CLICK",
+ value: { foo: "bar" },
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.sendEventPing(data);
+
+ const expectedPayload = {
+ client_id: FAKE_TELEMETRY_ID,
+ event: "CLICK",
+ browser_session_id: "fake_session_id",
+ value: JSON.stringify({ foo: "bar" }),
+ };
+ assert.calledWith(instance.sendStructuredIngestionEvent, expectedPayload);
+ });
+ });
+ describe("#sendSessionPing", () => {
+ it("should call sendStructuredIngestionEvent", async () => {
+ const data = {
+ action: "activity_stream_session",
+ page: "about:home",
+ session_duration: 10000,
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.sendSessionPing(data);
+
+ const expectedPayload = {
+ client_id: FAKE_TELEMETRY_ID,
+ page: "about:home",
+ session_duration: 10000,
+ };
+ assert.calledWith(instance.sendStructuredIngestionEvent, expectedPayload);
+ });
+ });
+ describe("#sendEvent", () => {
+ it("should call sendEventPing on activity_stream_user_event", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ const event = { action: "activity_stream_user_event" };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendEventPing");
+
+ instance.sendEvent(event);
+
+ assert.calledOnce(instance.sendEventPing);
+ });
+ it("should call sendSessionPing on activity_stream_session", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ const event = { action: "activity_stream_session" };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendSessionPing");
+
+ instance.sendEvent(event);
+
+ assert.calledOnce(instance.sendSessionPing);
+ });
+ });
+ describe("#sendUTEvent", () => {
+ it("should call the UT event function passed in", async () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ const event = {};
+ instance = new TelemetryFeed();
+ sandbox.stub(instance.utEvents, "sendUserEvent");
+
+ await instance.sendUTEvent(event, instance.utEvents.sendUserEvent);
+
+ assert.calledWith(instance.utEvents.sendUserEvent, event);
+ });
+ });
+ describe("#sendStructuredIngestionEvent", () => {
+ it("should call PingCentre sendStructuredIngestionPing", async () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ const event = {};
+ instance = new TelemetryFeed();
+ sandbox.stub(instance.pingCentre, "sendStructuredIngestionPing");
+
+ await instance.sendStructuredIngestionEvent(
+ event,
+ "http://foo.com/base/"
+ );
+
+ assert.calledWith(instance.pingCentre.sendStructuredIngestionPing, event);
+ });
+ });
+ describe("#setLoadTriggerInfo", () => {
+ it("should call saveSessionPerfData w/load_trigger_{ts,type} data", () => {
+ sandbox.stub(global.Cu, "now").returns(12345);
+
+ globals.set("ChromeUtils", {
+ addProfilerMarker: sandbox.stub(),
+ });
+
+ instance.browserOpenNewtabStart();
+
+ const stub = sandbox.stub(instance, "saveSessionPerfData");
+ instance.addSession("port123");
+
+ instance.setLoadTriggerInfo("port123");
+
+ assert.calledWith(stub, "port123", {
+ load_trigger_ts: 1588010448000 + 12345,
+ load_trigger_type: "menu_plus_or_keyboard",
+ });
+ });
+
+ it("should not call saveSessionPerfData when getting mark throws", () => {
+ const stub = sandbox.stub(instance, "saveSessionPerfData");
+ instance.addSession("port123");
+
+ instance.setLoadTriggerInfo("port123");
+
+ assert.notCalled(stub);
+ });
+ });
+
+ describe("#saveSessionPerfData", () => {
+ it("should update the given session with the given data", () => {
+ instance.addSession("port123");
+ assert.notProperty(instance.sessions.get("port123"), "fake_ts");
+ const data = { fake_ts: 456, other_fake_ts: 789 };
+
+ instance.saveSessionPerfData("port123", data);
+
+ assert.include(instance.sessions.get("port123").perf, data);
+ });
+
+ it("should call setLoadTriggerInfo if data has visibility_event_rcvd_ts", () => {
+ sandbox.stub(instance, "setLoadTriggerInfo");
+ instance.addSession("port123");
+ const data = { visibility_event_rcvd_ts: 444455 };
+
+ instance.saveSessionPerfData("port123", data);
+
+ assert.calledOnce(instance.setLoadTriggerInfo);
+ assert.calledWithExactly(instance.setLoadTriggerInfo, "port123");
+ assert.include(instance.sessions.get("port123").perf, data);
+ });
+
+ it("shouldn't call setLoadTriggerInfo if data has no visibility_event_rcvd_ts", () => {
+ sandbox.stub(instance, "setLoadTriggerInfo");
+ instance.addSession("port123");
+
+ instance.saveSessionPerfData("port123", { monkeys_ts: 444455 });
+
+ assert.notCalled(instance.setLoadTriggerInfo);
+ });
+
+ it("should not call setLoadTriggerInfo when url is about:home", () => {
+ sandbox.stub(instance, "setLoadTriggerInfo");
+ instance.addSession("port123", "about:home");
+ const data = { visibility_event_rcvd_ts: 444455 };
+
+ instance.saveSessionPerfData("port123", data);
+
+ assert.notCalled(instance.setLoadTriggerInfo);
+ });
+
+ it("should call maybeRecordTopsitesPainted when url is about:home and topsites_first_painted_ts is given", () => {
+ const topsites_first_painted_ts = 44455;
+ const data = { topsites_first_painted_ts };
+ const spy = sandbox.spy();
+
+ sandbox.stub(Services.prefs, "getIntPref").returns(1);
+ globals.set("AboutNewTab", {
+ maybeRecordTopsitesPainted: spy,
+ });
+ instance.addSession("port123", "about:home");
+ instance.saveSessionPerfData("port123", data);
+
+ assert.calledOnce(spy);
+ assert.calledWith(spy, topsites_first_painted_ts);
+ });
+ it("should record a Glean newtab.opened event with the correct visit_id when visibility event received", () => {
+ const session_id = "decafc0ffee";
+ const page = "about:newtab";
+ const session = { page, perf: {}, session_id };
+ const data = { visibility_event_rcvd_ts: 444455 };
+ sandbox.stub(instance.sessions, "get").returns(session);
+
+ sandbox.spy(Glean.newtab.opened, "record");
+ instance.saveSessionPerfData("port123", data);
+
+ assert.calledOnce(Glean.newtab.opened.record);
+ assert.deepEqual(Glean.newtab.opened.record.firstCall.args[0], {
+ newtab_visit_id: session_id,
+ source: page,
+ });
+ });
+ });
+ describe("#uninit", () => {
+ it("should call .pingCentre.uninit", () => {
+ const stub = sandbox.stub(instance.pingCentre, "uninit");
+
+ instance.uninit();
+
+ assert.calledOnce(stub);
+ });
+ it("should call .utEvents.uninit", () => {
+ const stub = sandbox.stub(instance.utEvents, "uninit");
+
+ instance.uninit();
+
+ assert.calledOnce(stub);
+ });
+ it("should make this.browserOpenNewtabStart() stop observing browser-open-newtab-start and domwindowopened", async () => {
+ await instance.init();
+ sandbox.spy(Services.obs, "removeObserver");
+ sandbox.stub(instance.pingCentre, "uninit");
+
+ await instance.uninit();
+
+ assert.calledTwice(Services.obs.removeObserver);
+ assert.calledWithExactly(
+ Services.obs.removeObserver,
+ instance.browserOpenNewtabStart,
+ "browser-open-newtab-start"
+ );
+ assert.calledWithExactly(
+ Services.obs.removeObserver,
+ instance._addWindowListeners,
+ "domwindowopened"
+ );
+ });
+ });
+ describe("#onAction", () => {
+ beforeEach(() => {
+ FAKE_GLOBAL_PREFS.clear();
+ });
+ it("should call .init() on an INIT action", () => {
+ const init = sandbox.stub(instance, "init");
+ const sendPageTakeoverData = sandbox.stub(
+ instance,
+ "sendPageTakeoverData"
+ );
+
+ instance.onAction({ type: at.INIT });
+
+ assert.calledOnce(init);
+ assert.calledOnce(sendPageTakeoverData);
+ });
+ it("should call .uninit() on an UNINIT action", () => {
+ const stub = sandbox.stub(instance, "uninit");
+
+ instance.onAction({ type: at.UNINIT });
+
+ assert.calledOnce(stub);
+ });
+ it("should call .handleNewTabInit on a NEW_TAB_INIT action", () => {
+ sandbox.spy(instance, "handleNewTabInit");
+
+ instance.onAction(
+ ac.AlsoToMain({
+ type: at.NEW_TAB_INIT,
+ data: { url: "about:newtab", browser },
+ })
+ );
+
+ assert.calledOnce(instance.handleNewTabInit);
+ });
+ it("should call .addSession() on a NEW_TAB_INIT action", () => {
+ const stub = sandbox.stub(instance, "addSession").returns({ perf: {} });
+ sandbox.stub(instance, "setLoadTriggerInfo");
+
+ instance.onAction(
+ ac.AlsoToMain(
+ {
+ type: at.NEW_TAB_INIT,
+ data: { url: "about:monkeys", browser },
+ },
+ "port123"
+ )
+ );
+
+ assert.calledOnce(stub);
+ assert.calledWith(stub, "port123", "about:monkeys");
+ });
+ it("should call .endSession() on a NEW_TAB_UNLOAD action", () => {
+ const stub = sandbox.stub(instance, "endSession");
+
+ instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "port123"));
+
+ assert.calledWith(stub, "port123");
+ });
+ it("should call .saveSessionPerfData on SAVE_SESSION_PERF_DATA", () => {
+ const stub = sandbox.stub(instance, "saveSessionPerfData");
+ const data = { some_ts: 10 };
+ const action = { type: at.SAVE_SESSION_PERF_DATA, data };
+
+ instance.onAction(ac.AlsoToMain(action, "port123"));
+
+ assert.calledWith(stub, "port123", data);
+ });
+ it("should send an event on a TELEMETRY_USER_EVENT action", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ instance = new TelemetryFeed();
+
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+ const utSendUserEvent = sandbox.stub(instance.utEvents, "sendUserEvent");
+ const eventCreator = sandbox.stub(instance, "createUserEvent");
+ const action = { type: at.TELEMETRY_USER_EVENT };
+
+ instance.onAction(action);
+
+ assert.calledWith(eventCreator, action);
+ assert.calledWith(sendEvent, eventCreator.returnValue);
+ assert.calledWith(utSendUserEvent, eventCreator.returnValue);
+ });
+ it("should send an event on a DISCOVERY_STREAM_USER_EVENT action", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ instance = new TelemetryFeed();
+
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+ const utSendUserEvent = sandbox.stub(instance.utEvents, "sendUserEvent");
+ const eventCreator = sandbox.stub(instance, "createUserEvent");
+ const action = { type: at.DISCOVERY_STREAM_USER_EVENT };
+
+ instance.onAction(action);
+
+ assert.calledWith(eventCreator, {
+ ...action,
+ data: {
+ value: {
+ pocket_logged_in_status: true,
+ },
+ },
+ });
+ assert.calledWith(sendEvent, eventCreator.returnValue);
+ assert.calledWith(utSendUserEvent, eventCreator.returnValue);
+ });
+ describe("should call handleASRouterUserEvent on x action", () => {
+ const actions = [
+ at.AS_ROUTER_TELEMETRY_USER_EVENT,
+ msg.TOOLBAR_BADGE_TELEMETRY,
+ msg.TOOLBAR_PANEL_TELEMETRY,
+ msg.MOMENTS_PAGE_TELEMETRY,
+ msg.DOORHANGER_TELEMETRY,
+ ];
+ actions.forEach(type => {
+ it(`${type} action`, () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ instance = new TelemetryFeed();
+
+ const eventHandler = sandbox.spy(instance, "handleASRouterUserEvent");
+ const action = {
+ type,
+ data: { event: "CLICK" },
+ };
+
+ instance.onAction(action);
+
+ assert.calledWith(eventHandler, action);
+ });
+ });
+ });
+ it("should send an event on a TELEMETRY_IMPRESSION_STATS action", () => {
+ const sendEvent = sandbox.stub(instance, "sendStructuredIngestionEvent");
+ const eventCreator = sandbox.stub(instance, "createImpressionStats");
+ const tiles = [{ id: 10001 }, { id: 10002 }, { id: 10003 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles });
+
+ instance.onAction(action);
+
+ assert.calledWith(
+ eventCreator,
+ au.getPortIdOfSender(action),
+ action.data
+ );
+ assert.calledWith(sendEvent, eventCreator.returnValue);
+ });
+ it("should call .handleDiscoveryStreamImpressionStats on a DISCOVERY_STREAM_IMPRESSION_STATS action", () => {
+ const session = {};
+ sandbox.stub(instance.sessions, "get").returns(session);
+ const data = { source: "foo", tiles: [{ id: 1 }] };
+ const action = { type: at.DISCOVERY_STREAM_IMPRESSION_STATS, data };
+ sandbox.spy(instance, "handleDiscoveryStreamImpressionStats");
+
+ instance.onAction(ac.AlsoToMain(action, "port123"));
+
+ assert.calledWith(
+ instance.handleDiscoveryStreamImpressionStats,
+ "port123",
+ data
+ );
+ });
+ it("should call .handleDiscoveryStreamLoadedContent on a DISCOVERY_STREAM_LOADED_CONTENT action", () => {
+ const session = {};
+ sandbox.stub(instance.sessions, "get").returns(session);
+ const data = { source: "foo", tiles: [{ id: 1 }] };
+ const action = { type: at.DISCOVERY_STREAM_LOADED_CONTENT, data };
+ sandbox.spy(instance, "handleDiscoveryStreamLoadedContent");
+
+ instance.onAction(ac.AlsoToMain(action, "port123"));
+
+ assert.calledWith(
+ instance.handleDiscoveryStreamLoadedContent,
+ "port123",
+ data
+ );
+ });
+ it("should call .handleTopSitesSponsoredImpressionStats on a TOP_SITES_SPONSORED_IMPRESSION_STATS action", () => {
+ const session = {};
+ sandbox.stub(instance.sessions, "get").returns(session);
+ const data = { type: "impression", tile_id: 42, position: 1 };
+ const action = { type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, data };
+ sandbox.spy(instance, "handleTopSitesSponsoredImpressionStats");
+
+ instance.onAction(ac.AlsoToMain(action));
+
+ assert.calledOnce(instance.handleTopSitesSponsoredImpressionStats);
+ assert.deepEqual(
+ instance.handleTopSitesSponsoredImpressionStats.firstCall.args[0].data,
+ data
+ );
+ });
+ });
+ it("should call .handleTopSitesOrganicImpressionStats on a TOP_SITES_ORGANIC_IMPRESSION_STATS action", () => {
+ const session = {};
+ sandbox.stub(instance.sessions, "get").returns(session);
+ const data = { type: "impression", position: 1 };
+ const action = { type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS, data };
+ sandbox.spy(instance, "handleTopSitesOrganicImpressionStats");
+
+ instance.onAction(ac.AlsoToMain(action));
+
+ assert.calledOnce(instance.handleTopSitesOrganicImpressionStats);
+ assert.deepEqual(
+ instance.handleTopSitesOrganicImpressionStats.firstCall.args[0].data,
+ data
+ );
+ });
+ describe("#handleNewTabInit", () => {
+ it("should set the session as preloaded if the browser is preloaded", () => {
+ const session = { perf: {} };
+ let preloadedBrowser = {
+ getAttribute() {
+ return "preloaded";
+ },
+ };
+ sandbox.stub(instance, "addSession").returns(session);
+
+ instance.onAction(
+ ac.AlsoToMain({
+ type: at.NEW_TAB_INIT,
+ data: { url: "about:newtab", browser: preloadedBrowser },
+ })
+ );
+
+ assert.ok(session.perf.is_preloaded);
+ });
+ it("should set the session as non-preloaded if the browser is non-preloaded", () => {
+ const session = { perf: {} };
+ let nonPreloadedBrowser = {
+ getAttribute() {
+ return "";
+ },
+ };
+ sandbox.stub(instance, "addSession").returns(session);
+
+ instance.onAction(
+ ac.AlsoToMain({
+ type: at.NEW_TAB_INIT,
+ data: { url: "about:newtab", browser: nonPreloadedBrowser },
+ })
+ );
+
+ assert.ok(!session.perf.is_preloaded);
+ });
+ });
+ describe("#SendASRouterUndesiredEvent", () => {
+ it("should call handleASRouterUserEvent", () => {
+ let stub = sandbox.stub(instance, "handleASRouterUserEvent");
+
+ instance.SendASRouterUndesiredEvent({ foo: "bar" });
+
+ assert.calledOnce(stub);
+ let [payload] = stub.firstCall.args;
+ assert.propertyVal(payload.data, "action", "asrouter_undesired_event");
+ assert.propertyVal(payload.data, "foo", "bar");
+ });
+ });
+ describe("#sendPageTakeoverData", () => {
+ let fakePrefs = { "browser.newtabpage.enabled": true };
+
+ beforeEach(() => {
+ globals.set(
+ "Services",
+ Object.assign({}, Services, {
+ prefs: { getBoolPref: key => fakePrefs[key] },
+ })
+ );
+ // Services.prefs = {getBoolPref: key => fakePrefs[key]};
+ sandbox.spy(Glean.newtab.newtabCategory, "set");
+ sandbox.spy(Glean.newtab.homepageCategory, "set");
+ });
+ it("should send correct event data for about:home set to custom URL", async () => {
+ fakeHomePageUrl = "https://searchprovider.com";
+ instance._prefs.set(TELEMETRY_PREF, true);
+ instance._classifySite = () => Promise.resolve("other");
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(sendEvent);
+ assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA");
+ assert.deepEqual(sendEvent.firstCall.args[0].value, {
+ home_url_category: "other",
+ });
+ assert.validate(sendEvent.firstCall.args[0], UserEventPing);
+ assert.calledOnce(Glean.newtab.homepageCategory.set);
+ assert.calledWith(Glean.newtab.homepageCategory.set, "other");
+ });
+ it("should send correct event data for about:newtab set to custom URL", async () => {
+ globals.set("AboutNewTab", {
+ newTabURLOverridden: true,
+ newTabURL: "https://searchprovider.com",
+ });
+ instance._prefs.set(TELEMETRY_PREF, true);
+ instance._classifySite = () => Promise.resolve("other");
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(sendEvent);
+ assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA");
+ assert.deepEqual(sendEvent.firstCall.args[0].value, {
+ newtab_url_category: "other",
+ });
+ assert.validate(sendEvent.firstCall.args[0], UserEventPing);
+ assert.calledOnce(Glean.newtab.newtabCategory.set);
+ assert.calledWith(Glean.newtab.newtabCategory.set, "other");
+ });
+ it("should not send an event if neither about:{home,newtab} are set to custom URL", async () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+
+ await instance.sendPageTakeoverData();
+ assert.notCalled(sendEvent);
+ assert.calledOnce(Glean.newtab.newtabCategory.set);
+ assert.calledOnce(Glean.newtab.homepageCategory.set);
+ assert.calledWith(Glean.newtab.newtabCategory.set, "enabled");
+ assert.calledWith(Glean.newtab.homepageCategory.set, "enabled");
+ });
+ it("should send home_extension_id and newtab_extension_id when appropriate", async () => {
+ const ID = "{abc-foo-bar}";
+ fakeExtensionSettingsStore.getSetting = () => ({ id: ID });
+ instance._prefs.set(TELEMETRY_PREF, true);
+ instance._classifySite = () => Promise.resolve("other");
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(sendEvent);
+ assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA");
+ assert.deepEqual(sendEvent.firstCall.args[0].value, {
+ home_extension_id: ID,
+ newtab_extension_id: ID,
+ });
+ assert.validate(sendEvent.firstCall.args[0], UserEventPing);
+ assert.calledOnce(Glean.newtab.newtabCategory.set);
+ assert.calledOnce(Glean.newtab.homepageCategory.set);
+ assert.equal(Glean.newtab.newtabCategory.set.args[0], "extension");
+ assert.equal(Glean.newtab.homepageCategory.set.args[0], "extension");
+ });
+ it("instruments when newtab is disabled", async () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+ fakePrefs["browser.newtabpage.enabled"] = false;
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(Glean.newtab.newtabCategory.set);
+ assert.calledWith(Glean.newtab.newtabCategory.set, "disabled");
+ });
+ it("instruments when homepage is disabled", async () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+ fakeHomePage.overridden = true;
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(Glean.newtab.homepageCategory.set);
+ assert.calledWith(Glean.newtab.homepageCategory.set, "disabled");
+ });
+ it("should send a 'newtab' ping", async () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+ sandbox.spy(GleanPings.newtab, "submit");
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(GleanPings.newtab.submit);
+ assert.calledWithExactly(GleanPings.newtab.submit, "component_init");
+ });
+ });
+ describe("#sendDiscoveryStreamImpressions", () => {
+ it("should not send impression pings if there is no impression data", () => {
+ const spy = sandbox.spy(instance, "sendEvent");
+ const session = {};
+ instance.sendDiscoveryStreamImpressions("foo", session);
+
+ assert.notCalled(spy);
+ });
+ it("should send impression pings if there is impression data", () => {
+ const spy = sandbox.spy(instance, "sendStructuredIngestionEvent");
+ const session = {
+ impressionSets: {
+ source_foo: [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ],
+ source_bar: [
+ { id: 3, pos: 0 },
+ { id: 4, pos: 1 },
+ ],
+ },
+ };
+ instance.sendDiscoveryStreamImpressions("foo", session);
+
+ assert.calledTwice(spy);
+ });
+ });
+ describe("#sendDiscoveryStreamLoadedContent", () => {
+ it("should not send loaded content pings if there is no loaded content data", () => {
+ const spy = sandbox.spy(instance, "sendEvent");
+ const session = {};
+ instance.sendDiscoveryStreamLoadedContent("foo", session);
+
+ assert.notCalled(spy);
+ });
+ it("should send loaded content pings if there is loaded content data", () => {
+ const spy = sandbox.spy(instance, "sendStructuredIngestionEvent");
+ const session = {
+ loadedContentSets: {
+ source_foo: [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ],
+ source_bar: [
+ { id: 3, pos: 0 },
+ { id: 4, pos: 1 },
+ ],
+ },
+ };
+ instance.sendDiscoveryStreamLoadedContent("foo", session);
+
+ assert.calledTwice(spy);
+
+ let [payload] = spy.firstCall.args;
+ let sources = new Set([]);
+ sources.add(payload.source);
+ assert.equal(payload.loaded, 2);
+ assert.deepEqual(
+ payload.tiles,
+ session.loadedContentSets[payload.source]
+ );
+
+ [payload] = spy.secondCall.args;
+ sources.add(payload.source);
+ assert.equal(payload.loaded, 2);
+ assert.deepEqual(
+ payload.tiles,
+ session.loadedContentSets[payload.source]
+ );
+
+ assert.deepEqual(sources, new Set(["source_foo", "source_bar"]));
+ });
+ });
+ describe("#handleDiscoveryStreamImpressionStats", () => {
+ it("should throw for a missing session", () => {
+ assert.throws(() => {
+ instance.handleDiscoveryStreamImpressionStats("a_missing_port", {});
+ }, "Session does not exist.");
+ });
+ it("should store impression to impressionSets", () => {
+ const session = instance.addSession("new_session", "about:newtab");
+ instance.handleDiscoveryStreamImpressionStats("new_session", {
+ source: "foo",
+ tiles: [{ id: 1, pos: 0 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ assert.equal(Object.keys(session.impressionSets).length, 1);
+ assert.deepEqual(session.impressionSets.foo, {
+ tiles: [{ id: 1, pos: 0 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ // Add another ping with the same source
+ instance.handleDiscoveryStreamImpressionStats("new_session", {
+ source: "foo",
+ tiles: [{ id: 2, pos: 1 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ assert.deepEqual(session.impressionSets.foo, {
+ tiles: [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ // Add another ping with a different source
+ instance.handleDiscoveryStreamImpressionStats("new_session", {
+ source: "bar",
+ tiles: [{ id: 3, pos: 2 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ assert.equal(Object.keys(session.impressionSets).length, 2);
+ assert.deepEqual(session.impressionSets.foo, {
+ tiles: [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+ assert.deepEqual(session.impressionSets.bar, {
+ tiles: [{ id: 3, pos: 2 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+ });
+ it("should instrument pocket impressions", () => {
+ const session_id = "1337cafe";
+ const pos1 = 1;
+ const pos2 = 4;
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.impression, "record");
+
+ instance.handleDiscoveryStreamImpressionStats("_", {
+ source: "foo",
+ tiles: [
+ { id: 1, pos: pos1, type: "organic" },
+ { id: 2, pos: pos2, type: "spoc" },
+ ],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ assert.calledTwice(Glean.pocket.impression.record);
+ assert.deepEqual(Glean.pocket.impression.record.firstCall.args[0], {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: pos1,
+ });
+ assert.deepEqual(Glean.pocket.impression.record.secondCall.args[0], {
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: pos2,
+ });
+ });
+ });
+ describe("#handleDiscoveryStreamLoadedContent", () => {
+ it("should throw for a missing session", () => {
+ assert.throws(() => {
+ instance.handleDiscoveryStreamLoadedContent("a_missing_port", {});
+ }, "Session does not exist.");
+ });
+ it("should store loaded content to loadedContentSets", () => {
+ const session = instance.addSession("new_session", "about:newtab");
+ instance.handleDiscoveryStreamLoadedContent("new_session", {
+ source: "foo",
+ tiles: [{ id: 1, pos: 0 }],
+ });
+
+ assert.equal(Object.keys(session.loadedContentSets).length, 1);
+ assert.deepEqual(session.loadedContentSets.foo, [{ id: 1, pos: 0 }]);
+
+ // Add another ping with the same source
+ instance.handleDiscoveryStreamLoadedContent("new_session", {
+ source: "foo",
+ tiles: [{ id: 2, pos: 1 }],
+ });
+
+ assert.deepEqual(session.loadedContentSets.foo, [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ]);
+
+ // Add another ping with a different source
+ instance.handleDiscoveryStreamLoadedContent("new_session", {
+ source: "bar",
+ tiles: [{ id: 3, pos: 2 }],
+ });
+
+ assert.equal(Object.keys(session.loadedContentSets).length, 2);
+ assert.deepEqual(session.loadedContentSets.foo, [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ]);
+ assert.deepEqual(session.loadedContentSets.bar, [{ id: 3, pos: 2 }]);
+ });
+ });
+ describe("#_generateStructuredIngestionEndpoint", () => {
+ it("should generate a valid endpoint", () => {
+ const fakeEndpoint = "http://fakeendpoint.com/base/";
+ const fakeUUID = "{34f24486-f01a-9749-9c5b-21476af1fa77}";
+ const fakeUUIDWithoutBraces = fakeUUID.substring(1, fakeUUID.length - 1);
+ FAKE_GLOBAL_PREFS.set(STRUCTURED_INGESTION_ENDPOINT_PREF, fakeEndpoint);
+ sandbox.stub(Services.uuid, "generateUUID").returns(fakeUUID);
+ const feed = new TelemetryFeed();
+ const url = feed._generateStructuredIngestionEndpoint(
+ "testNameSpace",
+ "testPingType",
+ "1"
+ );
+
+ assert.equal(
+ url,
+ `${fakeEndpoint}/testNameSpace/testPingType/1/${fakeUUIDWithoutBraces}`
+ );
+ });
+ });
+ describe("#handleASRouterUserEvent", () => {
+ it("should call sendStructuredIngestionEvent on known pingTypes", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "IMPRESSION",
+ message_id: "12345",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.handleASRouterUserEvent({ data });
+
+ assert.calledOnce(instance.sendStructuredIngestionEvent);
+ });
+ it("should call submitGleanPingForPing on known pingTypes when telemetry is enabled", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "IMPRESSION",
+ message_id: "12345",
+ };
+ instance = new TelemetryFeed();
+ instance._prefs.set(TELEMETRY_PREF, true);
+ sandbox.spy(
+ global.AboutWelcomeTelemetry.prototype,
+ "submitGleanPingForPing"
+ );
+
+ await instance.handleASRouterUserEvent({ data });
+
+ assert.calledOnce(
+ global.AboutWelcomeTelemetry.prototype.submitGleanPingForPing
+ );
+ });
+ it("should console.error and not submit pings on unknown pingTypes", async () => {
+ const data = {
+ action: "unknown_event",
+ event: "IMPRESSION",
+ message_id: "12345",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.handleASRouterUserEvent({ data });
+
+ assert.calledOnce(global.console.error);
+ assert.notCalled(instance.sendStructuredIngestionEvent);
+ });
+ });
+ describe("#isInCFRCohort", () => {
+ it("should return false if there is no CFR experiment registered", () => {
+ assert.ok(!instance.isInCFRCohort);
+ });
+ it("should return true if there is a CFR experiment registered", () => {
+ sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({
+ slug: "SOME-CFR-EXP",
+ });
+
+ assert.ok(instance.isInCFRCohort);
+ assert.propertyVal(
+ ExperimentAPI.getExperimentMetaData.firstCall.args[0],
+ "featureId",
+ "cfr"
+ );
+ });
+ });
+ describe("#handleTopSitesSponsoredImpressionStats", () => {
+ it("should call sendStructuredIngestionEvent on an impression event", async () => {
+ const data = {
+ type: "impression",
+ tile_id: 42,
+ source: "newtab",
+ position: 0,
+ reporting_url: "https://test.reporting.net/",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+ sandbox.spy(Services.telemetry, "keyedScalarAdd");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ // Scalar should be added
+ assert.calledOnce(Services.telemetry.keyedScalarAdd);
+ assert.calledWith(
+ Services.telemetry.keyedScalarAdd,
+ "contextual.services.topsites.impression",
+ "newtab_1",
+ 1
+ );
+
+ assert.calledOnce(instance.sendStructuredIngestionEvent);
+
+ const { args } = instance.sendStructuredIngestionEvent.firstCall;
+ // payload
+ assert.deepEqual(args[0], {
+ context_id: FAKE_UUID,
+ tile_id: 42,
+ source: "newtab",
+ position: 1,
+ reporting_url: "https://test.reporting.net/",
+ });
+ // namespace
+ assert.equal(args[1], "contextual-services");
+ // docType
+ assert.equal(args[2], "topsites-impression");
+ // version
+ assert.equal(args[3], "1");
+ });
+ it("should call sendStructuredIngestionEvent on a click event", async () => {
+ const data = {
+ type: "click",
+ tile_id: 42,
+ source: "newtab",
+ position: 0,
+ reporting_url: "https://test.reporting.net/",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+ sandbox.spy(Services.telemetry, "keyedScalarAdd");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ // Scalar should be added
+ assert.calledOnce(Services.telemetry.keyedScalarAdd);
+ assert.calledWith(
+ Services.telemetry.keyedScalarAdd,
+ "contextual.services.topsites.click",
+ "newtab_1",
+ 1
+ );
+
+ assert.calledOnce(instance.sendStructuredIngestionEvent);
+
+ const { args } = instance.sendStructuredIngestionEvent.firstCall;
+ // payload
+ assert.deepEqual(args[0], {
+ context_id: FAKE_UUID,
+ tile_id: 42,
+ source: "newtab",
+ position: 1,
+ reporting_url: "https://test.reporting.net/",
+ });
+ // namespace
+ assert.equal(args[1], "contextual-services");
+ // docType
+ assert.equal(args[2], "topsites-click");
+ // version
+ assert.equal(args[3], "1");
+ });
+ it("should record a Glean topsites.impression event on an impression event", async () => {
+ const data = {
+ type: "impression",
+ tile_id: 42,
+ source: "newtab",
+ position: 1,
+ reporting_url: "https://test.reporting.net/",
+ advertiser: "adnoid ads",
+ };
+ instance = new TelemetryFeed();
+ const session_id = "decafc0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.topsites.impression, "record");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ // Event should be recorded
+ assert.calledOnce(Glean.topsites.impression.record);
+ assert.calledWith(Glean.topsites.impression.record, {
+ advertiser_name: "adnoid ads",
+ tile_id: "42",
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: 1,
+ });
+ });
+ it("should record a Glean topsites.click event on a click event", async () => {
+ const data = {
+ type: "click",
+ advertiser: "test advertiser",
+ tile_id: 42,
+ source: "newtab",
+ position: 0,
+ reporting_url: "https://test.reporting.net/",
+ };
+ instance = new TelemetryFeed();
+ const session_id = "decafc0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.topsites.click, "record");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ // Event should be recorded
+ assert.calledOnce(Glean.topsites.click.record);
+ assert.calledWith(Glean.topsites.click.record, {
+ advertiser_name: "test advertiser",
+ tile_id: "42",
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: 0,
+ });
+ });
+ it("should console.error on unknown pingTypes", async () => {
+ const data = { type: "unknown_type" };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ assert.calledOnce(global.console.error);
+ assert.notCalled(instance.sendStructuredIngestionEvent);
+ });
+ });
+ describe("#handleTopSitesOrganicImpressionStats", () => {
+ it("should record a Glean topsites.impression event on an impression event", async () => {
+ const data = {
+ type: "impression",
+ source: "newtab",
+ position: 0,
+ };
+ instance = new TelemetryFeed();
+ const session_id = "decafc0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.topsites.impression, "record");
+
+ await instance.handleTopSitesOrganicImpressionStats({ data });
+
+ assert.calledOnce(Glean.topsites.impression.record);
+ assert.calledWith(Glean.topsites.impression.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: 0,
+ });
+ });
+ it("should record a Glean topsites.click event on a click event", async () => {
+ const data = {
+ type: "click",
+ source: "newtab",
+ position: 0,
+ };
+ instance = new TelemetryFeed();
+ const session_id = "decafc0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.topsites.click, "record");
+
+ await instance.handleTopSitesOrganicImpressionStats({ data });
+
+ assert.calledOnce(Glean.topsites.click.record);
+ assert.calledWith(Glean.topsites.click.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: 0,
+ });
+ });
+ });
+ describe("#handleDiscoveryStreamUserEvent", () => {
+ it("correctly handles action with no `data`", () => {
+ const action = ac.DiscoveryStreamUserEvent();
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+ sandbox.spy(Glean.pocket.click, "record");
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.notCalled(Glean.pocket.topicClick.record);
+ assert.notCalled(Glean.pocket.click.record);
+ assert.notCalled(Glean.pocket.save.record);
+ });
+ it("correctly handles CLICK data with no value", () => {
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "POPULAR_TOPICS",
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.topicClick.record);
+ assert.calledWith(Glean.pocket.topicClick.record, {
+ newtab_visit_id: session_id,
+ topic: undefined,
+ });
+ });
+ it("correctly handles non-POPULAR_TOPICS CLICK data with no value", () => {
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "not-POPULAR_TOPICS",
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+ sandbox.spy(Glean.pocket.click, "record");
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.notCalled(Glean.pocket.topicClick.record);
+ assert.notCalled(Glean.pocket.click.record);
+ assert.notCalled(Glean.pocket.save.record);
+ });
+ it("correctly handles CLICK data with non-POPULAR_TOPICS source", () => {
+ const topic = "atopic";
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "not-POPULAR_TOPICS",
+ value: {
+ card_type: "topics_widget",
+ topic,
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.topicClick.record);
+ assert.calledWith(Glean.pocket.topicClick.record, {
+ newtab_visit_id: session_id,
+ topic,
+ });
+ });
+ it("doesn't instrument a CLICK without a card_type", () => {
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "not-POPULAR_TOPICS",
+ value: {
+ card_type: "not spoc, organic, or topics_widget",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+ sandbox.spy(Glean.pocket.click, "record");
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.notCalled(Glean.pocket.topicClick.record);
+ assert.notCalled(Glean.pocket.click.record);
+ assert.notCalled(Glean.pocket.save.record);
+ });
+ it("instruments a popular topic click", () => {
+ const topic = "entertainment";
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "POPULAR_TOPICS",
+ value: {
+ card_type: "topics_widget",
+ topic,
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.topicClick.record);
+ assert.calledWith(Glean.pocket.topicClick.record, {
+ newtab_visit_id: session_id,
+ topic,
+ });
+ });
+ it("instruments an organic top stories click", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ action_position,
+ value: {
+ card_type: "organic",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.click, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.click.record);
+ assert.calledWith(Glean.pocket.click.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: action_position,
+ });
+ });
+ it("instruments a sponsored top stories click", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ action_position,
+ value: {
+ card_type: "spoc",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.click, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.click.record);
+ assert.calledWith(Glean.pocket.click.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: action_position,
+ });
+ });
+ it("instruments a save of an organic top story", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "SAVE_TO_POCKET",
+ action_position,
+ value: {
+ card_type: "organic",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.save.record);
+ assert.calledWith(Glean.pocket.save.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: action_position,
+ });
+ });
+ it("instruments a save of a sponsored top story", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "SAVE_TO_POCKET",
+ action_position,
+ value: {
+ card_type: "spoc",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.save.record);
+ assert.calledWith(Glean.pocket.save.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: action_position,
+ });
+ });
+ it("instruments a save of a sponsored top story, without `value`", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "SAVE_TO_POCKET",
+ action_position,
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.save.record);
+ assert.calledWith(Glean.pocket.save.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: action_position,
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/TippyTopProvider.test.js b/browser/components/newtab/test/unit/lib/TippyTopProvider.test.js
new file mode 100644
index 0000000000..661a6b7b83
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/TippyTopProvider.test.js
@@ -0,0 +1,121 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { TippyTopProvider } from "lib/TippyTopProvider.sys.mjs";
+
+describe("TippyTopProvider", () => {
+ let instance;
+ let globals;
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () =>
+ Promise.resolve([
+ {
+ domains: ["facebook.com"],
+ image_url: "images/facebook-com.png",
+ favicon_url: "images/facebook-com.png",
+ background_color: "#3b5998",
+ },
+ {
+ domains: ["gmail.com", "mail.google.com"],
+ image_url: "images/gmail-com.png",
+ favicon_url: "images/gmail-com.png",
+ background_color: "#000000",
+ },
+ ]),
+ });
+ instance = new TippyTopProvider();
+ await instance.init();
+ });
+ it("should provide an icon for facebook.com", () => {
+ const site = instance.processSite({ url: "https://facebook.com" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(site.backgroundColor, "#3b5998");
+ });
+ it("should provide an icon for www.facebook.com", () => {
+ const site = instance.processSite({ url: "https://www.facebook.com" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(site.backgroundColor, "#3b5998");
+ });
+ it("should not provide an icon for other.facebook.com", () => {
+ const site = instance.processSite({ url: "https://other.facebook.com" });
+ assert.isUndefined(site.tippyTopIcon);
+ });
+ it("should provide an icon for other.facebook.com with stripping", () => {
+ const site = instance.processSite(
+ { url: "https://other.facebook.com" },
+ "*"
+ );
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ });
+ it("should provide an icon for facebook.com/foobar", () => {
+ const site = instance.processSite({ url: "https://facebook.com/foobar" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(site.backgroundColor, "#3b5998");
+ });
+ it("should provide an icon for gmail.com", () => {
+ const site = instance.processSite({ url: "https://gmail.com" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png"
+ );
+ assert.equal(site.backgroundColor, "#000000");
+ });
+ it("should provide an icon for mail.google.com", () => {
+ const site = instance.processSite({ url: "https://mail.google.com" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png"
+ );
+ assert.equal(site.backgroundColor, "#000000");
+ });
+ it("should handle garbage URLs gracefully", () => {
+ const site = instance.processSite({ url: "garbagejlfkdsa" });
+ assert.isUndefined(site.tippyTopIcon);
+ assert.isUndefined(site.backgroundColor);
+ });
+ it("should handle error when fetching and parsing manifest", async () => {
+ globals = new GlobalOverrider();
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ fetchStub.rejects("whaaaa");
+ instance = new TippyTopProvider();
+ await instance.init();
+ instance.processSite({ url: "https://facebook.com" });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
new file mode 100644
index 0000000000..12e70557f6
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
@@ -0,0 +1,649 @@
+import { _ToolbarBadgeHub } from "lib/ToolbarBadgeHub.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
+import { _ToolbarPanelHub, ToolbarPanelHub } from "lib/ToolbarPanelHub.jsm";
+
+describe("ToolbarBadgeHub", () => {
+ let sandbox;
+ let instance;
+ let fakeAddImpression;
+ let fakeSendTelemetry;
+ let isBrowserPrivateStub;
+ let fxaMessage;
+ let whatsnewMessage;
+ let fakeElement;
+ let globals;
+ let everyWindowStub;
+ let clearTimeoutStub;
+ let setTimeoutStub;
+ let addObserverStub;
+ let removeObserverStub;
+ let getStringPrefStub;
+ let clearUserPrefStub;
+ let setStringPrefStub;
+ let requestIdleCallbackStub;
+ let fakeWindow;
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ instance = new _ToolbarBadgeHub();
+ fakeAddImpression = sandbox.stub();
+ fakeSendTelemetry = sandbox.stub();
+ isBrowserPrivateStub = sandbox.stub();
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ fxaMessage = onboardingMsgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE");
+ whatsnewMessage = {
+ id: `WHATS_NEW_BADGE_71`,
+ template: "toolbar_badge",
+ content: {
+ delay: 1000,
+ target: "whats-new-menu-button",
+ action: { id: "show-whatsnew-button" },
+ badgeDescription: { string_id: "cfr-badge-reader-label-newfeature" },
+ },
+ priority: 1,
+ trigger: { id: "toolbarBadgeUpdate" },
+ frequency: {
+ // Makes it so that we track impressions for this message while at the
+ // same time it can have unlimited impressions
+ lifetime: Infinity,
+ },
+ // Never saw this message or saw it in the past 4 days or more recent
+ targeting: `isWhatsNewPanelEnabled &&
+ (!messageImpressions['WHATS_NEW_BADGE_71'] ||
+ (messageImpressions['WHATS_NEW_BADGE_71']|length >= 1 &&
+ currentDate|date - messageImpressions['WHATS_NEW_BADGE_71'][0] <= 4 * 24 * 3600 * 1000))`,
+ };
+ fakeElement = {
+ classList: {
+ add: sandbox.stub(),
+ remove: sandbox.stub(),
+ },
+ setAttribute: sandbox.stub(),
+ removeAttribute: sandbox.stub(),
+ querySelector: sandbox.stub(),
+ addEventListener: sandbox.stub(),
+ remove: sandbox.stub(),
+ appendChild: sandbox.stub(),
+ };
+ // Share the same element when selecting child nodes
+ fakeElement.querySelector.returns(fakeElement);
+ everyWindowStub = {
+ registerCallback: sandbox.stub(),
+ unregisterCallback: sandbox.stub(),
+ };
+ clearTimeoutStub = sandbox.stub();
+ setTimeoutStub = sandbox.stub();
+ fakeWindow = {
+ MozXULElement: { insertFTLIfNeeded: sandbox.stub() },
+ ownerGlobal: {
+ gBrowser: {
+ selectedBrowser: "browser",
+ },
+ },
+ };
+ addObserverStub = sandbox.stub();
+ removeObserverStub = sandbox.stub();
+ getStringPrefStub = sandbox.stub();
+ clearUserPrefStub = sandbox.stub();
+ setStringPrefStub = sandbox.stub();
+ requestIdleCallbackStub = sandbox.stub().callsFake(fn => fn());
+ globals.set({
+ ToolbarPanelHub,
+ requestIdleCallback: requestIdleCallbackStub,
+ EveryWindow: everyWindowStub,
+ PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub },
+ setTimeout: setTimeoutStub,
+ clearTimeout: clearTimeoutStub,
+ Services: {
+ wm: {
+ getMostRecentWindow: () => fakeWindow,
+ },
+ prefs: {
+ addObserver: addObserverStub,
+ removeObserver: removeObserverStub,
+ getStringPref: getStringPrefStub,
+ clearUserPref: clearUserPrefStub,
+ setStringPref: setStringPrefStub,
+ },
+ },
+ });
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+ it("should create an instance", () => {
+ assert.ok(instance);
+ });
+ describe("#init", () => {
+ it("should make a single messageRequest on init", async () => {
+ sandbox.stub(instance, "messageRequest");
+ const waitForInitialized = sandbox.stub().resolves();
+
+ await instance.init(waitForInitialized, {});
+ await instance.init(waitForInitialized, {});
+ assert.calledOnce(instance.messageRequest);
+ assert.calledWithExactly(instance.messageRequest, {
+ template: "toolbar_badge",
+ triggerId: "toolbarBadgeUpdate",
+ });
+
+ instance.uninit();
+
+ await instance.init(waitForInitialized, {});
+
+ assert.calledTwice(instance.messageRequest);
+ });
+ it("should add a pref observer", async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+
+ assert.calledOnce(addObserverStub);
+ assert.calledWithExactly(
+ addObserverStub,
+ instance.prefs.WHATSNEW_TOOLBAR_PANEL,
+ instance
+ );
+ });
+ });
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+ });
+ it("should clear any setTimeout cbs", async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+
+ instance.state.showBadgeTimeoutId = 2;
+
+ instance.uninit();
+
+ assert.calledOnce(clearTimeoutStub);
+ assert.calledWithExactly(clearTimeoutStub, 2);
+ });
+ it("should remove the pref observer", () => {
+ instance.uninit();
+
+ assert.calledOnce(removeObserverStub);
+ assert.calledWithExactly(
+ removeObserverStub,
+ instance.prefs.WHATSNEW_TOOLBAR_PANEL,
+ instance
+ );
+ });
+ });
+ describe("messageRequest", () => {
+ let handleMessageRequestStub;
+ beforeEach(() => {
+ handleMessageRequestStub = sandbox.stub().returns(fxaMessage);
+ sandbox
+ .stub(instance, "_handleMessageRequest")
+ .value(handleMessageRequestStub);
+ sandbox.stub(instance, "registerBadgeNotificationListener");
+ });
+ it("should fetch a message with the provided trigger and template", async () => {
+ await instance.messageRequest({
+ triggerId: "trigger",
+ template: "template",
+ });
+
+ assert.calledOnce(handleMessageRequestStub);
+ assert.calledWithExactly(handleMessageRequestStub, {
+ triggerId: "trigger",
+ template: "template",
+ });
+ });
+ it("should call addToolbarNotification with browser window and message", async () => {
+ await instance.messageRequest("trigger");
+
+ assert.calledOnce(instance.registerBadgeNotificationListener);
+ assert.calledWithExactly(
+ instance.registerBadgeNotificationListener,
+ fxaMessage
+ );
+ });
+ it("shouldn't do anything if no message is provided", async () => {
+ handleMessageRequestStub.resolves(null);
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.notCalled(instance.registerBadgeNotificationListener);
+ });
+ it("should record telemetry events", async () => {
+ const startTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "start"
+ );
+ const finishTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "finish"
+ );
+ handleMessageRequestStub.returns(null);
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(startTelemetryStopwatch);
+ assert.calledWithExactly(
+ startTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ assert.calledOnce(finishTelemetryStopwatch);
+ assert.calledWithExactly(
+ finishTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ });
+ });
+ describe("addToolbarNotification", () => {
+ let target;
+ let fakeDocument;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: fakeAddImpression,
+ sendTelemetry: fakeSendTelemetry,
+ });
+ fakeDocument = {
+ getElementById: sandbox.stub().returns(fakeElement),
+ createElement: sandbox.stub().returns(fakeElement),
+ l10n: { setAttributes: sandbox.stub() },
+ };
+ target = { ...fakeWindow, browser: { ownerDocument: fakeDocument } };
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("shouldn't do anything if target element is not found", () => {
+ fakeDocument.getElementById.returns(null);
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.notCalled(fakeElement.setAttribute);
+ });
+ it("should target the element specified in the message", () => {
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.calledOnce(fakeDocument.getElementById);
+ assert.calledWithExactly(
+ fakeDocument.getElementById,
+ fxaMessage.content.target
+ );
+ });
+ it("should show a notification", () => {
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.calledOnce(fakeElement.setAttribute);
+ assert.calledWithExactly(fakeElement.setAttribute, "badged", true);
+ assert.calledWithExactly(fakeElement.classList.add, "feature-callout");
+ });
+ it("should attach a cb on the notification", () => {
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.calledTwice(fakeElement.addEventListener);
+ assert.calledWithExactly(
+ fakeElement.addEventListener,
+ "mousedown",
+ instance.removeAllNotifications
+ );
+ assert.calledWithExactly(
+ fakeElement.addEventListener,
+ "keypress",
+ instance.removeAllNotifications
+ );
+ });
+ it("should execute actions if they exist", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(instance.executeAction);
+ assert.calledWithExactly(instance.executeAction, {
+ ...whatsnewMessage.content.action,
+ message_id: whatsnewMessage.id,
+ });
+ });
+ it("should create a description element", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(fakeDocument.createElement);
+ assert.calledWithExactly(fakeDocument.createElement, "span");
+ });
+ it("should set description id to element and to button", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledWithExactly(
+ fakeElement.setAttribute,
+ "id",
+ "toolbarbutton-notification-description"
+ );
+ assert.calledWithExactly(
+ fakeElement.setAttribute,
+ "aria-labelledby",
+ `toolbarbutton-notification-description ${whatsnewMessage.content.target}`
+ );
+ });
+ it("should attach fluent id to description", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(fakeDocument.l10n.setAttributes);
+ assert.calledWithExactly(
+ fakeDocument.l10n.setAttributes,
+ fakeElement,
+ whatsnewMessage.content.badgeDescription.string_id
+ );
+ });
+ it("should add an impression for the message", () => {
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(instance._addImpression);
+ assert.calledWithExactly(instance._addImpression, whatsnewMessage);
+ });
+ it("should send an impression ping", async () => {
+ sandbox.stub(instance, "sendUserEventTelemetry");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(instance.sendUserEventTelemetry);
+ assert.calledWithExactly(
+ instance.sendUserEventTelemetry,
+ "IMPRESSION",
+ whatsnewMessage
+ );
+ });
+ });
+ describe("registerBadgeNotificationListener", () => {
+ let msg_no_delay;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: fakeAddImpression,
+ sendTelemetry: fakeSendTelemetry,
+ });
+ sandbox.stub(instance, "addToolbarNotification").returns(fakeElement);
+ sandbox.stub(instance, "removeToolbarNotification");
+ msg_no_delay = {
+ ...fxaMessage,
+ content: {
+ ...fxaMessage.content,
+ delay: 0,
+ },
+ };
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("should register a callback that adds/removes the notification", () => {
+ instance.registerBadgeNotificationListener(msg_no_delay);
+
+ assert.calledOnce(everyWindowStub.registerCallback);
+ assert.calledWithExactly(
+ everyWindowStub.registerCallback,
+ instance.id,
+ sinon.match.func,
+ sinon.match.func
+ );
+
+ const [, initFn, uninitFn] =
+ everyWindowStub.registerCallback.firstCall.args;
+
+ initFn(window);
+ // Test that it doesn't try to add a second notification
+ initFn(window);
+
+ assert.calledOnce(instance.addToolbarNotification);
+ assert.calledWithExactly(
+ instance.addToolbarNotification,
+ window,
+ msg_no_delay
+ );
+
+ uninitFn(window);
+
+ assert.calledOnce(instance.removeToolbarNotification);
+ assert.calledWithExactly(instance.removeToolbarNotification, fakeElement);
+ });
+ it("should unregister notifications when forcing a badge via devtools", () => {
+ instance.registerBadgeNotificationListener(msg_no_delay, { force: true });
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id);
+ });
+ it("should only call executeAction for 'update_action' messages", () => {
+ const stub = sandbox.stub(instance, "executeAction");
+ const updateActionMsg = { ...msg_no_delay, template: "update_action" };
+
+ instance.registerBadgeNotificationListener(updateActionMsg);
+
+ assert.notCalled(everyWindowStub.registerCallback);
+ assert.calledOnce(stub);
+ });
+ });
+ describe("executeAction", () => {
+ let blockMessageByIdStub;
+ beforeEach(async () => {
+ blockMessageByIdStub = sandbox.stub();
+ await instance.init(sandbox.stub().resolves(), {
+ blockMessageById: blockMessageByIdStub,
+ });
+ });
+ it("should call ToolbarPanelHub.enableToolbarButton", () => {
+ const stub = sandbox.stub(
+ _ToolbarPanelHub.prototype,
+ "enableToolbarButton"
+ );
+
+ instance.executeAction({ id: "show-whatsnew-button" });
+
+ assert.calledOnce(stub);
+ });
+ it("should call ToolbarPanelHub.enableAppmenuButton", () => {
+ const stub = sandbox.stub(
+ _ToolbarPanelHub.prototype,
+ "enableAppmenuButton"
+ );
+
+ instance.executeAction({ id: "show-whatsnew-button" });
+
+ assert.calledOnce(stub);
+ });
+ });
+ describe("removeToolbarNotification", () => {
+ it("should remove the notification", () => {
+ instance.removeToolbarNotification(fakeElement);
+
+ assert.calledThrice(fakeElement.removeAttribute);
+ assert.calledWithExactly(fakeElement.removeAttribute, "badged");
+ assert.calledWithExactly(fakeElement.removeAttribute, "aria-labelledby");
+ assert.calledWithExactly(fakeElement.removeAttribute, "aria-describedby");
+ assert.calledOnce(fakeElement.classList.remove);
+ assert.calledWithExactly(fakeElement.classList.remove, "feature-callout");
+ assert.calledOnce(fakeElement.remove);
+ });
+ });
+ describe("removeAllNotifications", () => {
+ let blockMessageByIdStub;
+ let fakeEvent;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ sendTelemetry: fakeSendTelemetry,
+ });
+ blockMessageByIdStub = sandbox.stub();
+ sandbox.stub(instance, "_blockMessageById").value(blockMessageByIdStub);
+ instance.state = { notification: { id: fxaMessage.id } };
+ fakeEvent = { target: { removeEventListener: sandbox.stub() } };
+ });
+ it("should call to block the message", () => {
+ instance.removeAllNotifications();
+
+ assert.calledOnce(blockMessageByIdStub);
+ assert.calledWithExactly(blockMessageByIdStub, fxaMessage.id);
+ });
+ it("should remove the window listener", () => {
+ instance.removeAllNotifications();
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id);
+ });
+ it("should ignore right mouse button (mousedown event)", () => {
+ fakeEvent.type = "mousedown";
+ fakeEvent.button = 1; // not left click
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.notCalled(fakeEvent.target.removeEventListener);
+ assert.notCalled(everyWindowStub.unregisterCallback);
+ });
+ it("should ignore right mouse button (click event)", () => {
+ fakeEvent.type = "click";
+ fakeEvent.button = 1; // not left click
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.notCalled(fakeEvent.target.removeEventListener);
+ assert.notCalled(everyWindowStub.unregisterCallback);
+ });
+ it("should ignore keypresses that are not meant to focus the target", () => {
+ fakeEvent.type = "keypress";
+ fakeEvent.key = "\t"; // not enter
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.notCalled(fakeEvent.target.removeEventListener);
+ assert.notCalled(everyWindowStub.unregisterCallback);
+ });
+ it("should remove the event listeners after succesfully focusing the element", () => {
+ fakeEvent.type = "click";
+ fakeEvent.button = 0;
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.calledTwice(fakeEvent.target.removeEventListener);
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "mousedown",
+ instance.removeAllNotifications
+ );
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "keypress",
+ instance.removeAllNotifications
+ );
+ });
+ it("should send telemetry", () => {
+ fakeEvent.type = "click";
+ fakeEvent.button = 0;
+ sandbox.stub(instance, "sendUserEventTelemetry");
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.calledOnce(instance.sendUserEventTelemetry);
+ assert.calledWithExactly(instance.sendUserEventTelemetry, "CLICK", {
+ id: "FXA_ACCOUNTS_BADGE",
+ });
+ });
+ it("should remove the event listeners after succesfully focusing the element", () => {
+ fakeEvent.type = "keypress";
+ fakeEvent.key = "Enter";
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.calledTwice(fakeEvent.target.removeEventListener);
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "mousedown",
+ instance.removeAllNotifications
+ );
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "keypress",
+ instance.removeAllNotifications
+ );
+ });
+ });
+ describe("message with delay", () => {
+ let msg_with_delay;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: fakeAddImpression,
+ });
+ msg_with_delay = {
+ ...fxaMessage,
+ content: {
+ ...fxaMessage.content,
+ delay: 500,
+ },
+ };
+ sandbox.stub(instance, "registerBadgeToAllWindows");
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("should register a cb to fire after msg.content.delay ms", () => {
+ instance.registerBadgeNotificationListener(msg_with_delay);
+
+ assert.calledOnce(setTimeoutStub);
+ assert.calledWithExactly(
+ setTimeoutStub,
+ sinon.match.func,
+ msg_with_delay.content.delay
+ );
+
+ const [cb] = setTimeoutStub.firstCall.args;
+
+ assert.notCalled(instance.registerBadgeToAllWindows);
+
+ cb();
+
+ assert.calledOnce(instance.registerBadgeToAllWindows);
+ assert.calledWithExactly(
+ instance.registerBadgeToAllWindows,
+ msg_with_delay
+ );
+ // Delayed actions should be executed inside requestIdleCallback
+ assert.calledOnce(requestIdleCallbackStub);
+ });
+ });
+ describe("#sendUserEventTelemetry", () => {
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ sendTelemetry: fakeSendTelemetry,
+ });
+ });
+ it("should check for private window and not send", () => {
+ isBrowserPrivateStub.returns(true);
+
+ instance.sendUserEventTelemetry("CLICK", { id: fxaMessage });
+
+ assert.notCalled(instance._sendTelemetry);
+ });
+ it("should check for private window and send", () => {
+ isBrowserPrivateStub.returns(false);
+
+ instance.sendUserEventTelemetry("CLICK", { id: fxaMessage });
+
+ assert.calledOnce(fakeSendTelemetry);
+ const [ping] = instance._sendTelemetry.firstCall.args;
+ assert.propertyVal(ping, "type", "TOOLBAR_BADGE_TELEMETRY");
+ assert.propertyVal(ping.data, "event", "CLICK");
+ });
+ });
+ describe("#observe", () => {
+ it("should make a message request when the whats new pref is changed", () => {
+ sandbox.stub(instance, "messageRequest");
+
+ instance.observe("", "", instance.prefs.WHATSNEW_TOOLBAR_PANEL);
+
+ assert.calledOnce(instance.messageRequest);
+ assert.calledWithExactly(instance.messageRequest, {
+ template: "toolbar_badge",
+ triggerId: "toolbarBadgeUpdate",
+ });
+ });
+ it("should not react to other pref changes", () => {
+ sandbox.stub(instance, "messageRequest");
+
+ instance.observe("", "", "foo");
+
+ assert.notCalled(instance.messageRequest);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
new file mode 100644
index 0000000000..36fcc0cbe3
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
@@ -0,0 +1,934 @@
+import { _ToolbarPanelHub } from "lib/ToolbarPanelHub.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
+import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs";
+
+describe("ToolbarPanelHub", () => {
+ let globals;
+ let sandbox;
+ let instance;
+ let everyWindowStub;
+ let preferencesStub;
+ let fakeDocument;
+ let fakeWindow;
+ let fakeElementById;
+ let fakeElementByTagName;
+ let createdCustomElements = [];
+ let eventListeners = {};
+ let addObserverStub;
+ let removeObserverStub;
+ let getBoolPrefStub;
+ let setBoolPrefStub;
+ let waitForInitializedStub;
+ let isBrowserPrivateStub;
+ let fakeSendTelemetry;
+ let getEarliestRecordedDateStub;
+ let getEventsByDateRangeStub;
+ let defaultSearchStub;
+ let scriptloaderStub;
+ let fakeRemoteL10n;
+ let getViewNodeStub;
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ instance = new _ToolbarPanelHub();
+ waitForInitializedStub = sandbox.stub().resolves();
+ fakeElementById = {
+ setAttribute: sandbox.stub(),
+ removeAttribute: sandbox.stub(),
+ querySelector: sandbox.stub().returns(null),
+ querySelectorAll: sandbox.stub().returns([]),
+ appendChild: sandbox.stub(),
+ addEventListener: sandbox.stub(),
+ hasAttribute: sandbox.stub(),
+ toggleAttribute: sandbox.stub(),
+ remove: sandbox.stub(),
+ removeChild: sandbox.stub(),
+ };
+ fakeElementByTagName = {
+ setAttribute: sandbox.stub(),
+ removeAttribute: sandbox.stub(),
+ querySelector: sandbox.stub().returns(null),
+ querySelectorAll: sandbox.stub().returns([]),
+ appendChild: sandbox.stub(),
+ addEventListener: sandbox.stub(),
+ hasAttribute: sandbox.stub(),
+ toggleAttribute: sandbox.stub(),
+ remove: sandbox.stub(),
+ removeChild: sandbox.stub(),
+ };
+ fakeDocument = {
+ getElementById: sandbox.stub().returns(fakeElementById),
+ getElementsByTagName: sandbox.stub().returns(fakeElementByTagName),
+ querySelector: sandbox.stub().returns({}),
+ createElement: tagName => {
+ const element = {
+ tagName,
+ classList: {},
+ addEventListener: (ev, fn) => {
+ eventListeners[ev] = fn;
+ },
+ appendChild: sandbox.stub(),
+ setAttribute: sandbox.stub(),
+ textContent: "",
+ };
+ element.classList.add = sandbox.stub();
+ element.classList.includes = className =>
+ element.classList.add.firstCall.args[0] === className;
+ createdCustomElements.push(element);
+ return element;
+ },
+ l10n: {
+ translateElements: sandbox.stub(),
+ translateFragment: sandbox.stub(),
+ formatMessages: sandbox.stub().resolves([{}]),
+ setAttributes: sandbox.stub(),
+ },
+ };
+ fakeWindow = {
+ // eslint-disable-next-line object-shorthand
+ DocumentFragment: function () {
+ return fakeElementById;
+ },
+ document: fakeDocument,
+ browser: {
+ ownerDocument: fakeDocument,
+ },
+ MozXULElement: { insertFTLIfNeeded: sandbox.stub() },
+ ownerGlobal: {
+ openLinkIn: sandbox.stub(),
+ gBrowser: "gBrowser",
+ },
+ PanelUI: {
+ panel: fakeElementById,
+ whatsNewPanel: fakeElementById,
+ },
+ customElements: { get: sandbox.stub() },
+ };
+ everyWindowStub = {
+ registerCallback: sandbox.stub(),
+ unregisterCallback: sandbox.stub(),
+ };
+ preferencesStub = {
+ get: sandbox.stub(),
+ set: sandbox.stub(),
+ };
+ scriptloaderStub = { loadSubScript: sandbox.stub() };
+ addObserverStub = sandbox.stub();
+ removeObserverStub = sandbox.stub();
+ getBoolPrefStub = sandbox.stub();
+ setBoolPrefStub = sandbox.stub();
+ fakeSendTelemetry = sandbox.stub();
+ isBrowserPrivateStub = sandbox.stub();
+ getEarliestRecordedDateStub = sandbox.stub().returns(
+ // A random date that's not the current timestamp
+ new Date() - 500
+ );
+ getEventsByDateRangeStub = sandbox.stub().returns([]);
+ getViewNodeStub = sandbox.stub().returns(fakeElementById);
+ defaultSearchStub = { defaultEngine: { name: "DDG" } };
+ fakeRemoteL10n = {
+ l10n: {},
+ reloadL10n: sandbox.stub(),
+ createElement: sandbox
+ .stub()
+ .callsFake((doc, el) => fakeDocument.createElement(el)),
+ };
+ globals.set({
+ EveryWindow: everyWindowStub,
+ Services: {
+ ...Services,
+ prefs: {
+ addObserver: addObserverStub,
+ removeObserver: removeObserverStub,
+ getBoolPref: getBoolPrefStub,
+ setBoolPref: setBoolPrefStub,
+ },
+ search: defaultSearchStub,
+ scriptloader: scriptloaderStub,
+ },
+ PrivateBrowsingUtils: {
+ isBrowserPrivate: isBrowserPrivateStub,
+ },
+ Preferences: preferencesStub,
+ TrackingDBService: {
+ getEarliestRecordedDate: getEarliestRecordedDateStub,
+ getEventsByDateRange: getEventsByDateRangeStub,
+ },
+ SpecialMessageActions: {
+ handleAction: sandbox.stub(),
+ },
+ RemoteL10n: fakeRemoteL10n,
+ PanelMultiView: {
+ getViewNode: getViewNodeStub,
+ },
+ });
+ });
+ afterEach(() => {
+ instance.uninit();
+ sandbox.restore();
+ globals.restore();
+ eventListeners = {};
+ createdCustomElements = [];
+ });
+ it("should create an instance", () => {
+ assert.ok(instance);
+ });
+ it("should enableAppmenuButton() on init() just once", async () => {
+ instance.enableAppmenuButton = sandbox.stub();
+
+ await instance.init(waitForInitializedStub, { getMessages: () => {} });
+ await instance.init(waitForInitializedStub, { getMessages: () => {} });
+
+ assert.calledOnce(instance.enableAppmenuButton);
+
+ instance.uninit();
+
+ await instance.init(waitForInitializedStub, { getMessages: () => {} });
+
+ assert.calledTwice(instance.enableAppmenuButton);
+ });
+ it("should unregisterCallback on uninit()", () => {
+ instance.uninit();
+ assert.calledTwice(everyWindowStub.unregisterCallback);
+ });
+ describe("#maybeLoadCustomElement", () => {
+ it("should not load customElements a second time", () => {
+ instance.maybeLoadCustomElement({ customElements: new Map() });
+ instance.maybeLoadCustomElement({
+ customElements: new Map([["remote-text", true]]),
+ });
+
+ assert.calledOnce(scriptloaderStub.loadSubScript);
+ });
+ });
+ describe("#toggleWhatsNewPref", () => {
+ it("should call Preferences.set() with the opposite value", () => {
+ let checkbox = {};
+ let event = { target: checkbox };
+ // checkbox starts false
+ checkbox.checked = false;
+
+ // toggling the checkbox to set the value to true;
+ // Preferences.set() gets called before the checkbox changes,
+ // so we have to call it with the opposite value.
+ instance.toggleWhatsNewPref(event);
+
+ assert.calledOnce(preferencesStub.set);
+ assert.calledWith(
+ preferencesStub.set,
+ "browser.messaging-system.whatsNewPanel.enabled",
+ !checkbox.checked
+ );
+ });
+ it("should report telemetry with the opposite value", () => {
+ let sendUserEventTelemetryStub = sandbox.stub(
+ instance,
+ "sendUserEventTelemetry"
+ );
+ let event = {
+ target: { checked: true, ownerGlobal: fakeWindow },
+ };
+
+ instance.toggleWhatsNewPref(event);
+
+ assert.calledOnce(sendUserEventTelemetryStub);
+ const { args } = sendUserEventTelemetryStub.firstCall;
+ assert.equal(args[1], "WNP_PREF_TOGGLE");
+ assert.propertyVal(args[3].value, "prefValue", false);
+ });
+ });
+ describe("#enableAppmenuButton", () => {
+ it("should registerCallback on enableAppmenuButton() if there are messages", async () => {
+ await instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([{}, {}]),
+ });
+ // init calls `enableAppmenuButton`
+ everyWindowStub.registerCallback.resetHistory();
+
+ await instance.enableAppmenuButton();
+
+ assert.calledOnce(everyWindowStub.registerCallback);
+ assert.calledWithExactly(
+ everyWindowStub.registerCallback,
+ "appMenu-whatsnew-button",
+ sinon.match.func,
+ sinon.match.func
+ );
+ });
+ it("should not registerCallback on enableAppmenuButton() if there are no messages", async () => {
+ instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([]),
+ });
+ // init calls `enableAppmenuButton`
+ everyWindowStub.registerCallback.resetHistory();
+
+ await instance.enableAppmenuButton();
+
+ assert.notCalled(everyWindowStub.registerCallback);
+ });
+ });
+ describe("#disableAppmenuButton", () => {
+ it("should call the unregisterCallback", () => {
+ assert.notCalled(everyWindowStub.unregisterCallback);
+
+ instance.disableAppmenuButton();
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(
+ everyWindowStub.unregisterCallback,
+ "appMenu-whatsnew-button"
+ );
+ });
+ });
+ describe("#enableToolbarButton", () => {
+ it("should registerCallback on enableToolbarButton if messages.length", async () => {
+ await instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([{}, {}]),
+ });
+ // init calls `enableAppmenuButton`
+ everyWindowStub.registerCallback.resetHistory();
+
+ await instance.enableToolbarButton();
+
+ assert.calledOnce(everyWindowStub.registerCallback);
+ assert.calledWithExactly(
+ everyWindowStub.registerCallback,
+ "whats-new-menu-button",
+ sinon.match.func,
+ sinon.match.func
+ );
+ });
+ it("should not registerCallback on enableToolbarButton if no messages", async () => {
+ await instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([]),
+ });
+
+ await instance.enableToolbarButton();
+
+ assert.notCalled(everyWindowStub.registerCallback);
+ });
+ });
+ describe("Show/Hide functions", () => {
+ it("should unhide appmenu button on _showAppmenuButton()", async () => {
+ await instance._showAppmenuButton(fakeWindow);
+
+ assert.equal(fakeElementById.hidden, false);
+ });
+ it("should hide appmenu button on _hideAppmenuButton()", () => {
+ instance._hideAppmenuButton(fakeWindow);
+ assert.equal(fakeElementById.hidden, true);
+ });
+ it("should not do anything if the window is closed", () => {
+ instance._hideAppmenuButton(fakeWindow, true);
+ assert.notCalled(global.PanelMultiView.getViewNode);
+ });
+ it("should not throw if the element does not exist", () => {
+ let fn = instance._hideAppmenuButton.bind(null, {
+ browser: { ownerDocument: {} },
+ });
+ getViewNodeStub.returns(undefined);
+ assert.doesNotThrow(fn);
+ });
+ it("should unhide toolbar button on _showToolbarButton()", async () => {
+ await instance._showToolbarButton(fakeWindow);
+
+ assert.equal(fakeElementById.hidden, false);
+ });
+ it("should hide toolbar button on _hideToolbarButton()", () => {
+ instance._hideToolbarButton(fakeWindow);
+ assert.equal(fakeElementById.hidden, true);
+ });
+ });
+ describe("#renderMessages", () => {
+ let getMessagesStub;
+ beforeEach(() => {
+ getMessagesStub = sandbox.stub();
+ instance.init(waitForInitializedStub, {
+ getMessages: getMessagesStub,
+ sendTelemetry: fakeSendTelemetry,
+ });
+ });
+ it("should have correct state", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+
+ getMessagesStub.returns(messages);
+ const ev1 = sandbox.stub();
+ ev1.withArgs("type").returns(1); // tracker
+ ev1.withArgs("count").returns(4);
+ const ev2 = sandbox.stub();
+ ev2.withArgs("type").returns(4); // fingerprinter
+ ev2.withArgs("count").returns(3);
+ getEventsByDateRangeStub.returns([
+ { getResultByName: ev1 },
+ { getResultByName: ev2 },
+ ]);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.propertyVal(instance.state.contentArguments, "trackerCount", 4);
+ assert.propertyVal(
+ instance.state.contentArguments,
+ "fingerprinterCount",
+ 3
+ );
+ });
+ it("should render messages to the panel on renderMessages()", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ messages[0].content.link_text = { string_id: "link_text_id" };
+
+ getMessagesStub.returns(messages);
+ const ev1 = sandbox.stub();
+ ev1.withArgs("type").returns(1); // tracker
+ ev1.withArgs("count").returns(4);
+ const ev2 = sandbox.stub();
+ ev2.withArgs("type").returns(4); // fingerprinter
+ ev2.withArgs("count").returns(3);
+ getEventsByDateRangeStub.returns([
+ { getResultByName: ev1 },
+ { getResultByName: ev2 },
+ ]);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ for (let message of messages) {
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-title"
+ )
+ );
+ if (message.content.layout === "tracking-protections") {
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-subtitle"
+ )
+ );
+ }
+ if (message.id === "WHATS_NEW_FINGERPRINTER_COUNTER_72") {
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) => el === "h2" && args.content === 3
+ )
+ );
+ }
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-content"
+ )
+ );
+ }
+ // Call the click handler to make coverage happy.
+ eventListeners.mouseup();
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ });
+ it("should clear previous messages on 2nd renderMessages()", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ const removeStub = sandbox.stub();
+ fakeElementById.querySelectorAll.onCall(0).returns([]);
+ fakeElementById.querySelectorAll
+ .onCall(1)
+ .returns([{ remove: removeStub }, { remove: removeStub }]);
+
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledTwice(removeStub);
+ });
+ it("should sort based on order field value", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m =>
+ m.template === "whatsnew_panel_message" &&
+ m.content.published_date === 1560969794394
+ );
+
+ messages.forEach(m => (m.content.title = m.order));
+
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ // Select the title elements that are supposed to be set to the same
+ // value as the `order` field of the message
+ const titleEls = fakeRemoteL10n.createElement.args
+ .filter(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-title"
+ )
+ .map(([doc, el, args]) => args.content);
+ assert.deepEqual(titleEls, [1, 2, 3]);
+ });
+ it("should accept string for image attributes", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.id === "WHATS_NEW_70_1"
+ );
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const imageEl = createdCustomElements.find(el => el.tagName === "img");
+ assert.calledOnce(imageEl.setAttribute);
+ assert.calledWithExactly(
+ imageEl.setAttribute,
+ "alt",
+ "Firefox Send Logo"
+ );
+ });
+ it("should set state values as data-attribute", async () => {
+ const message = (await PanelTestProvider.getMessages()).find(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns([message]);
+ instance.state.contentArguments = { foo: "foo", bar: "bar" };
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const [, , args] = fakeRemoteL10n.createElement.args.find(
+ ([doc, el, elArgs]) => elArgs && elArgs.attributes
+ );
+ assert.ok(args);
+ // Currently this.state.contentArguments has 8 different entries
+ assert.lengthOf(Object.keys(args.attributes), 8);
+ assert.equal(
+ args.attributes.searchEngineName,
+ defaultSearchStub.defaultEngine.name
+ );
+ });
+ it("should only render unique dates (no duplicates)", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ const uniqueDates = [
+ ...new Set(messages.map(m => m.content.published_date)),
+ ];
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const dateElements = fakeRemoteL10n.createElement.args.filter(
+ ([doc, el, args]) =>
+ el === "p" && args.classList === "whatsNew-message-date"
+ );
+ assert.lengthOf(dateElements, uniqueDates.length);
+ });
+ it("should listen for panelhidden and remove the toolbar button", async () => {
+ getMessagesStub.returns([]);
+ fakeDocument.getElementById
+ .withArgs("customizationui-widget-panel")
+ .returns(null);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.notCalled(fakeElementById.addEventListener);
+ });
+ it("should attach doCommand cbs that handle user actions", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const messageEl = createdCustomElements.find(
+ el =>
+ el.tagName === "div" && el.classList.includes("whatsNew-message-body")
+ );
+ const anchorEl = createdCustomElements.find(el => el.tagName === "a");
+
+ assert.notCalled(global.SpecialMessageActions.handleAction);
+
+ messageEl.doCommand();
+ anchorEl.doCommand();
+
+ assert.calledTwice(global.SpecialMessageActions.handleAction);
+ });
+ it("should listen for panelhidden and remove the toolbar button", async () => {
+ getMessagesStub.returns([]);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(fakeElementById.addEventListener);
+ assert.calledWithExactly(
+ fakeElementById.addEventListener,
+ "popuphidden",
+ sinon.match.func,
+ {
+ once: true,
+ }
+ );
+ const [, cb] = fakeElementById.addEventListener.firstCall.args;
+
+ assert.notCalled(everyWindowStub.unregisterCallback);
+
+ cb();
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(
+ everyWindowStub.unregisterCallback,
+ "whats-new-menu-button"
+ );
+ });
+ describe("#IMPRESSION", () => {
+ it("should dispatch a IMPRESSION for messages", async () => {
+ // means panel is triggered from the toolbar button
+ fakeElementById.hasAttribute.returns(true);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns(messages);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledOnce(fakeSendTelemetry);
+ assert.propertyVal(
+ spy.firstCall.args[2],
+ "id",
+ messages
+ .map(({ id }) => id)
+ .sort()
+ .join(",")
+ );
+ });
+ it("should dispatch a CLICK for clicking a message", async () => {
+ // means panel is triggered from the toolbar button
+ fakeElementById.hasAttribute.returns(true);
+ // Force to render the message
+ fakeElementById.querySelector.returns(null);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns([messages[0]]);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledOnce(fakeSendTelemetry);
+
+ spy.resetHistory();
+
+ // Message click event listener cb
+ eventListeners.mouseup();
+
+ assert.calledOnce(spy);
+ assert.calledWithExactly(spy, fakeWindow, "CLICK", messages[0]);
+ });
+ it("should dispatch a IMPRESSION with toolbar_dropdown", async () => {
+ // means panel is triggered from the toolbar button
+ fakeElementById.hasAttribute.returns(true);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.resolves(messages);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+ const panelPingId = messages
+ .map(({ id }) => id)
+ .sort()
+ .join(",");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledWithExactly(
+ spy,
+ fakeWindow,
+ "IMPRESSION",
+ {
+ id: panelPingId,
+ },
+ {
+ value: {
+ view: "toolbar_dropdown",
+ },
+ }
+ );
+ assert.calledOnce(fakeSendTelemetry);
+ const {
+ args: [dispatchPayload],
+ } = fakeSendTelemetry.lastCall;
+ assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY");
+ assert.propertyVal(dispatchPayload.data, "message_id", panelPingId);
+ assert.deepEqual(dispatchPayload.data.event_context, {
+ view: "toolbar_dropdown",
+ });
+ });
+ it("should dispatch a IMPRESSION with application_menu", async () => {
+ // means panel is triggered as a subview in the application menu
+ fakeElementById.hasAttribute.returns(false);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.resolves(messages);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+ const panelPingId = messages
+ .map(({ id }) => id)
+ .sort()
+ .join(",");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledWithExactly(
+ spy,
+ fakeWindow,
+ "IMPRESSION",
+ {
+ id: panelPingId,
+ },
+ {
+ value: {
+ view: "application_menu",
+ },
+ }
+ );
+ assert.calledOnce(fakeSendTelemetry);
+ const {
+ args: [dispatchPayload],
+ } = fakeSendTelemetry.lastCall;
+ assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY");
+ assert.propertyVal(dispatchPayload.data, "message_id", panelPingId);
+ assert.deepEqual(dispatchPayload.data.event_context, {
+ view: "application_menu",
+ });
+ });
+ });
+ describe("#forceShowMessage", () => {
+ const panelSelector = "PanelUI-whatsNew-message-container";
+ let removeMessagesSpy;
+ let renderMessagesStub;
+ let addEventListenerStub;
+ let messages;
+ let browser;
+ beforeEach(async () => {
+ messages = (await PanelTestProvider.getMessages()).find(
+ m => m.id === "WHATS_NEW_70_1"
+ );
+ removeMessagesSpy = sandbox.spy(instance, "removeMessages");
+ renderMessagesStub = sandbox.spy(instance, "renderMessages");
+ addEventListenerStub = fakeElementById.addEventListener;
+ browser = { ownerGlobal: fakeWindow, ownerDocument: fakeDocument };
+ fakeElementById.querySelectorAll.returns([fakeElementById]);
+ });
+ it("should call removeMessages when forcing a message to show", () => {
+ instance.forceShowMessage(browser, messages);
+
+ assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector);
+ });
+ it("should call renderMessages when forcing a message to show", () => {
+ instance.forceShowMessage(browser, messages);
+
+ assert.calledOnce(renderMessagesStub);
+ assert.calledWithExactly(
+ renderMessagesStub,
+ fakeWindow,
+ fakeDocument,
+ panelSelector,
+ {
+ force: true,
+ messages: Array.isArray(messages) ? messages : [messages],
+ }
+ );
+ });
+ it("should cleanup after the panel is hidden when forcing a message to show", () => {
+ instance.forceShowMessage(browser, messages);
+
+ assert.calledOnce(addEventListenerStub);
+ assert.calledWithExactly(
+ addEventListenerStub,
+ "popuphidden",
+ sinon.match.func
+ );
+
+ const [, cb] = addEventListenerStub.firstCall.args;
+ // Reset the call count from the first `forceShowMessage` call
+ removeMessagesSpy.resetHistory();
+ cb({ target: { ownerGlobal: fakeWindow } });
+
+ assert.calledOnce(removeMessagesSpy);
+ assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector);
+ });
+ it("should exit gracefully if called before a browser exists", () => {
+ instance.forceShowMessage(null, messages);
+ assert.neverCalledWith(removeMessagesSpy, fakeWindow, panelSelector);
+ });
+ });
+ });
+ describe("#insertProtectionPanelMessage", () => {
+ const fakeInsert = () =>
+ instance.insertProtectionPanelMessage({
+ target: { ownerGlobal: fakeWindow, ownerDocument: fakeDocument },
+ });
+ let getMessagesStub;
+ beforeEach(async () => {
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ getMessagesStub = sandbox
+ .stub()
+ .resolves(
+ onboardingMsgs.find(msg => msg.template === "protections_panel")
+ );
+ await instance.init(waitForInitializedStub, {
+ sendTelemetry: fakeSendTelemetry,
+ getMessages: getMessagesStub,
+ });
+ });
+ it("should remember it showed", async () => {
+ await fakeInsert();
+
+ assert.calledWithExactly(
+ setBoolPrefStub,
+ "browser.protections_panel.infoMessage.seen",
+ true
+ );
+ });
+ it("should toggle/expand when default collapsed/disabled", async () => {
+ fakeElementById.hasAttribute.returns(true);
+
+ await fakeInsert();
+
+ assert.calledThrice(fakeElementById.toggleAttribute);
+ });
+ it("should toggle again when popup hides", async () => {
+ fakeElementById.addEventListener.callsArg(1);
+
+ await fakeInsert();
+
+ assert.callCount(fakeElementById.toggleAttribute, 6);
+ });
+ it("should open link on click (separate link element)", async () => {
+ const sendTelemetryStub = sandbox.stub(
+ instance,
+ "sendUserEventTelemetry"
+ );
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const msg = onboardingMsgs.find(m => m.template === "protections_panel");
+
+ await fakeInsert();
+
+ assert.calledOnce(sendTelemetryStub);
+ assert.calledWithExactly(
+ sendTelemetryStub,
+ fakeWindow,
+ "IMPRESSION",
+ msg
+ );
+
+ eventListeners.mouseup();
+
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ assert.calledWithExactly(
+ global.SpecialMessageActions.handleAction,
+ {
+ type: "OPEN_URL",
+ data: {
+ args: sinon.match.string,
+ where: "tabshifted",
+ },
+ },
+ fakeWindow.browser
+ );
+ });
+ it("should format the url", async () => {
+ const stub = sandbox
+ .stub(global.Services.urlFormatter, "formatURL")
+ .returns("formattedURL");
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const msg = onboardingMsgs.find(m => m.template === "protections_panel");
+
+ await fakeInsert();
+
+ eventListeners.mouseup();
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, msg.content.cta_url);
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ assert.calledWithExactly(
+ global.SpecialMessageActions.handleAction,
+ {
+ type: "OPEN_URL",
+ data: {
+ args: "formattedURL",
+ where: "tabshifted",
+ },
+ },
+ fakeWindow.browser
+ );
+ });
+ it("should report format url errors", async () => {
+ const stub = sandbox
+ .stub(global.Services.urlFormatter, "formatURL")
+ .throws();
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const msg = onboardingMsgs.find(m => m.template === "protections_panel");
+ sandbox.spy(global.console, "error");
+
+ await fakeInsert();
+
+ eventListeners.mouseup();
+
+ assert.calledOnce(stub);
+ assert.calledOnce(global.console.error);
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ assert.calledWithExactly(
+ global.SpecialMessageActions.handleAction,
+ {
+ type: "OPEN_URL",
+ data: {
+ args: msg.content.cta_url,
+ where: "tabshifted",
+ },
+ },
+ fakeWindow.browser
+ );
+ });
+ it("should open link on click (directly attached to the message)", async () => {
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const msg = onboardingMsgs.find(m => m.template === "protections_panel");
+ getMessagesStub.resolves({
+ ...msg,
+ content: { ...msg.content, link_text: null },
+ });
+ await fakeInsert();
+
+ eventListeners.mouseup();
+
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ assert.calledWithExactly(
+ global.SpecialMessageActions.handleAction,
+ {
+ type: "OPEN_URL",
+ data: {
+ args: sinon.match.string,
+ where: "tabshifted",
+ },
+ },
+ fakeWindow.browser
+ );
+ });
+ it("should handle user actions from mouseup and keyup", async () => {
+ await fakeInsert();
+
+ eventListeners.mouseup();
+ eventListeners.keyup({ key: "Enter" });
+ eventListeners.keyup({ key: " " });
+ assert.calledThrice(global.SpecialMessageActions.handleAction);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js b/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js
new file mode 100644
index 0000000000..a173c16cde
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js
@@ -0,0 +1,3020 @@
+"use strict";
+
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { FAKE_GLOBAL_PREFS, FakePrefs, GlobalOverrider } from "test/unit/utils";
+import {
+ insertPinned,
+ TOP_SITES_DEFAULT_ROWS,
+ TOP_SITES_MAX_SITES_PER_ROW,
+} from "common/Reducers.sys.mjs";
+import { getDefaultOptions } from "lib/ActivityStreamStorage.jsm";
+import injector from "inject!lib/TopSitesFeed.jsm";
+import { Screenshots } from "lib/Screenshots.jsm";
+import { LinksCache } from "lib/LinksCache.sys.mjs";
+
+const FAKE_FAVICON = "data987";
+const FAKE_FAVICON_SIZE = 128;
+const FAKE_FRECENCY = 200;
+const FAKE_LINKS = new Array(2 * TOP_SITES_MAX_SITES_PER_ROW)
+ .fill(null)
+ .map((v, i) => ({
+ frecency: FAKE_FRECENCY,
+ url: `http://www.site${i}.com`,
+ }));
+const FAKE_SCREENSHOT = "data123";
+const SEARCH_SHORTCUTS_EXPERIMENT_PREF = "improvesearch.topSiteSearchShortcuts";
+const SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF =
+ "improvesearch.topSiteSearchShortcuts.searchEngines";
+const SEARCH_SHORTCUTS_HAVE_PINNED_PREF =
+ "improvesearch.topSiteSearchShortcuts.havePinned";
+const SHOWN_ON_NEWTAB_PREF = "feeds.topsites";
+const SHOW_SPONSORED_PREF = "showSponsoredTopSites";
+const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors";
+const REMOTE_SETTING_DEFAULTS_PREF = "browser.topsites.useRemoteSetting";
+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";
+
+function FakeTippyTopProvider() {}
+FakeTippyTopProvider.prototype = {
+ async init() {
+ this.initialized = true;
+ },
+ processSite(site) {
+ return site;
+ },
+};
+
+describe("Top Sites Feed", () => {
+ let TopSitesFeed;
+ let DEFAULT_TOP_SITES;
+ let feed;
+ let globals;
+ let sandbox;
+ let links;
+ let fakeNewTabUtils;
+ let fakeScreenshot;
+ let filterAdultStub;
+ let shortURLStub;
+ let fakePageThumbs;
+ let fetchStub;
+ let fakeNimbusFeatures;
+ let fakeSampling;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ fakeNewTabUtils = {
+ blockedLinks: {
+ links: [],
+ isBlocked: () => false,
+ unblock: sandbox.spy(),
+ },
+ activityStreamLinks: {
+ getTopSites: sandbox.spy(() => Promise.resolve(links)),
+ },
+ activityStreamProvider: {
+ _addFavicons: sandbox.spy(l =>
+ Promise.resolve(
+ l.map(link => {
+ link.favicon = FAKE_FAVICON;
+ link.faviconSize = FAKE_FAVICON_SIZE;
+ return link;
+ })
+ )
+ ),
+ _faviconBytesToDataURI: sandbox.spy(),
+ },
+ pinnedLinks: {
+ links: [],
+ isPinned: () => false,
+ pin: sandbox.spy(),
+ unpin: sandbox.spy(),
+ },
+ };
+ fakeScreenshot = {
+ getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_SCREENSHOT)),
+ maybeCacheScreenshot: sandbox.spy(Screenshots.maybeCacheScreenshot),
+ _shouldGetScreenshots: sinon.stub().returns(true),
+ };
+ filterAdultStub = {
+ filter: sinon.stub().returnsArg(0),
+ };
+ shortURLStub = sinon
+ .stub()
+ .callsFake(site =>
+ site.url.replace(/(.com|.ca)/, "").replace("https://", "")
+ );
+ const fakeDedupe = function () {};
+ fakePageThumbs = {
+ addExpirationFilter: sinon.stub(),
+ removeExpirationFilter: sinon.stub(),
+ };
+ fakeNimbusFeatures = {
+ newtab: {
+ getVariable: sinon.stub(),
+ onUpdate: sinon.stub(),
+ offUpdate: sinon.stub(),
+ },
+ pocketNewtab: {
+ getVariable: sinon.stub(),
+ },
+ };
+ fakeSampling = {
+ ratioSample: sinon.stub(),
+ };
+ globals.set({
+ PageThumbs: fakePageThumbs,
+ NewTabUtils: fakeNewTabUtils,
+ gFilterAdultEnabled: false,
+ NimbusFeatures: fakeNimbusFeatures,
+ LinksCache,
+ FilterAdult: filterAdultStub,
+ Screenshots: fakeScreenshot,
+ Sampling: fakeSampling,
+ });
+ sandbox.spy(global.XPCOMUtils, "defineLazyGetter");
+ FAKE_GLOBAL_PREFS.set("default.sites", "https://foo.com/");
+ ({ TopSitesFeed, DEFAULT_TOP_SITES } = injector({
+ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
+ "common/Dedupe.jsm": { Dedupe: fakeDedupe },
+ "common/Reducers.jsm": {
+ insertPinned,
+ TOP_SITES_DEFAULT_ROWS,
+ TOP_SITES_MAX_SITES_PER_ROW,
+ },
+ "lib/FilterAdult.jsm": { FilterAdult: filterAdultStub },
+ "lib/Screenshots.jsm": { Screenshots: fakeScreenshot },
+ "lib/TippyTopProvider.sys.mjs": {
+ TippyTopProvider: FakeTippyTopProvider,
+ },
+ "lib/ShortURL.jsm": { shortURL: shortURLStub },
+ "lib/ActivityStreamStorage.jsm": {
+ ActivityStreamStorage: function Fake() {},
+ getDefaultOptions,
+ },
+ }));
+ feed = new TopSitesFeed();
+ const storage = {
+ init: sandbox.stub().resolves(),
+ get: sandbox.stub().resolves(),
+ set: sandbox.stub().resolves(),
+ };
+ // Setup for tests that don't call `init` but require feed.storage
+ feed._storage = storage;
+ feed.store = {
+ dispatch: sinon.spy(),
+ getState() {
+ return this.state;
+ },
+ state: {
+ Prefs: { values: { topSitesRows: 2 } },
+ TopSites: { rows: Array(12).fill("site") },
+ },
+ dbStorage: { getDbTable: sandbox.stub().returns(storage) },
+ };
+ feed.dedupe.group = (...sites) => sites;
+ links = FAKE_LINKS;
+ // Turn off the search shortcuts experiment by default for other tests
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false;
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] =
+ "google,amazon";
+ });
+ afterEach(() => {
+ globals.restore();
+ sandbox.restore();
+ });
+
+ function stubFaviconsToUseScreenshots() {
+ fakeNewTabUtils.activityStreamProvider._addFavicons = sandbox.stub();
+ }
+
+ describe("#constructor", () => {
+ it("should defineLazyGetter for log, contextId, and _currentSearchHostname", () => {
+ assert.calledThrice(global.XPCOMUtils.defineLazyGetter);
+
+ let spyCall = global.XPCOMUtils.defineLazyGetter.getCall(0);
+ assert.ok(spyCall.calledWith(sinon.match.any, "log", sinon.match.func));
+
+ spyCall = global.XPCOMUtils.defineLazyGetter.getCall(1);
+ assert.ok(
+ spyCall.calledWith(sinon.match.any, "contextId", sinon.match.func)
+ );
+
+ spyCall = global.XPCOMUtils.defineLazyGetter.getCall(2);
+ assert.ok(
+ spyCall.calledWith(feed, "_currentSearchHostname", sinon.match.func)
+ );
+ });
+ });
+
+ describe("#refreshDefaults", () => {
+ it("should add defaults on PREFS_INITIAL_VALUES", () => {
+ feed.onAction({
+ type: at.PREFS_INITIAL_VALUES,
+ data: { "default.sites": "https://foo.com" },
+ });
+
+ assert.isAbove(DEFAULT_TOP_SITES.length, 0);
+ });
+ it("should add defaults on default.sites PREF_CHANGED", () => {
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "default.sites", value: "https://foo.com" },
+ });
+
+ assert.isAbove(DEFAULT_TOP_SITES.length, 0);
+ });
+ it("should refresh on topSiteRows PREF_CHANGED", () => {
+ feed.refresh = sinon.spy();
+ feed.onAction({ type: at.PREF_CHANGED, data: { name: "topSitesRows" } });
+
+ assert.calledOnce(feed.refresh);
+ });
+ it("should have default sites with .isDefault = true", () => {
+ feed.refreshDefaults("https://foo.com");
+
+ DEFAULT_TOP_SITES.forEach(link =>
+ assert.propertyVal(link, "isDefault", true)
+ );
+ });
+ it("should have default sites with appropriate hostname", () => {
+ feed.refreshDefaults("https://foo.com");
+
+ DEFAULT_TOP_SITES.forEach(link =>
+ assert.propertyVal(link, "hostname", shortURLStub(link))
+ );
+ });
+ it("should add no defaults on empty pref", () => {
+ feed.refreshDefaults("");
+
+ assert.equal(DEFAULT_TOP_SITES.length, 0);
+ });
+ it("should clear defaults", () => {
+ feed.refreshDefaults("https://foo.com");
+ feed.refreshDefaults("");
+
+ assert.equal(DEFAULT_TOP_SITES.length, 0);
+ });
+ });
+ describe("#filterForThumbnailExpiration", () => {
+ it("should pass rows.urls to the callback provided", () => {
+ const rows = [
+ { url: "foo.com" },
+ { url: "bar.com", customScreenshotURL: "custom" },
+ ];
+ feed.store.state.TopSites = { rows };
+ const stub = sinon.stub();
+
+ feed.filterForThumbnailExpiration(stub);
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, ["foo.com", "bar.com", "custom"]);
+ });
+ });
+ describe("#getLinksWithDefaults", () => {
+ beforeEach(() => {
+ feed.refreshDefaults("https://foo.com");
+ });
+
+ describe("general", () => {
+ it("should get the links from NewTabUtils", async () => {
+ const result = await feed.getLinksWithDefaults();
+ const reference = links.map(site =>
+ Object.assign({}, site, {
+ hostname: shortURLStub(site),
+ typedBonus: true,
+ })
+ );
+
+ assert.deepEqual(result, reference);
+ assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites);
+ });
+ it("should indicate the links get typed bonus", async () => {
+ const result = await feed.getLinksWithDefaults();
+
+ assert.propertyVal(result[0], "typedBonus", true);
+ });
+ it("should filter out non-pinned adult sites", async () => {
+ filterAdultStub.filter = sinon.stub().returns([]);
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+
+ const result = await feed.getLinksWithDefaults();
+
+ // The stub filters out everything
+ assert.calledOnce(filterAdultStub.filter);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].url, fakeNewTabUtils.pinnedLinks.links[0].url);
+ });
+ it("should filter out the defaults that have been blocked", async () => {
+ // make sure we only have one top site, and we block the only default site we have to show
+ const url = "www.myonlytopsite.com";
+ const topsite = {
+ frecency: FAKE_FRECENCY,
+ hostname: shortURLStub({ url }),
+ typedBonus: true,
+ url,
+ };
+ const blockedDefaultSite = { url: "https://foo.com" };
+ fakeNewTabUtils.activityStreamLinks.getTopSites = () => [topsite];
+ fakeNewTabUtils.blockedLinks.isBlocked = site =>
+ site.url === blockedDefaultSite.url;
+ const result = await feed.getLinksWithDefaults();
+
+ // what we should be left with is just the top site we added, and not the default site we blocked
+ assert.lengthOf(result, 1);
+ assert.deepEqual(result[0], topsite);
+ assert.notInclude(result, blockedDefaultSite);
+ });
+ it("should call dedupe on the links", async () => {
+ const stub = sinon.stub(feed.dedupe, "group").callsFake((...id) => id);
+
+ await feed.getLinksWithDefaults();
+
+ assert.calledOnce(stub);
+ });
+ it("should dedupe the links by hostname", async () => {
+ const site = { url: "foo", hostname: "bar" };
+ const result = feed._dedupeKey(site);
+
+ assert.equal(result, site.hostname);
+ });
+ it("should add defaults if there are are not enough links", async () => {
+ links = [{ frecency: FAKE_FRECENCY, url: "foo.com" }];
+
+ const result = await feed.getLinksWithDefaults();
+ const reference = [...links, ...DEFAULT_TOP_SITES].map(s =>
+ Object.assign({}, s, {
+ hostname: shortURLStub(s),
+ typedBonus: true,
+ })
+ );
+
+ assert.deepEqual(result, reference);
+ });
+ it("should only add defaults up to the number of visible slots", async () => {
+ links = [];
+ const numVisible = TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW;
+ for (let i = 0; i < numVisible - 1; i++) {
+ links.push({ frecency: FAKE_FRECENCY, url: `foo${i}.com` });
+ }
+ const result = await feed.getLinksWithDefaults();
+ const reference = [...links, DEFAULT_TOP_SITES[0]].map(s =>
+ Object.assign({}, s, {
+ hostname: shortURLStub(s),
+ typedBonus: true,
+ })
+ );
+
+ assert.lengthOf(result, numVisible);
+ assert.deepEqual(result, reference);
+ });
+ it("should not throw if NewTabUtils returns null", () => {
+ links = null;
+ assert.doesNotThrow(() => {
+ feed.getLinksWithDefaults();
+ });
+ });
+ it("should get more if the user has asked for more", async () => {
+ links = new Array(4 * TOP_SITES_MAX_SITES_PER_ROW)
+ .fill(null)
+ .map((v, i) => ({
+ frecency: FAKE_FRECENCY,
+ url: `http://www.site${i}.com`,
+ }));
+ feed.store.state.Prefs.values.topSitesRows = 3;
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.propertyVal(
+ result,
+ "length",
+ feed.store.state.Prefs.values.topSitesRows *
+ TOP_SITES_MAX_SITES_PER_ROW
+ );
+ });
+ });
+ describe("caching", () => {
+ it("should reuse the cache on subsequent calls", async () => {
+ await feed.getLinksWithDefaults();
+ await feed.getLinksWithDefaults();
+
+ assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites);
+ });
+ it("should ignore the cache when requesting more", async () => {
+ await feed.getLinksWithDefaults();
+ feed.store.state.Prefs.values.topSitesRows *= 3;
+
+ await feed.getLinksWithDefaults();
+
+ assert.calledTwice(global.NewTabUtils.activityStreamLinks.getTopSites);
+ });
+ it("should migrate frecent screenshot data without getting screenshots again", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ stubFaviconsToUseScreenshots();
+ await feed.getLinksWithDefaults();
+ const { callCount } = fakeScreenshot.getScreenshotForURL;
+ feed.frecentCache.expire();
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.calledTwice(global.NewTabUtils.activityStreamLinks.getTopSites);
+ assert.callCount(fakeScreenshot.getScreenshotForURL, callCount);
+ assert.propertyVal(result[0], "screenshot", FAKE_SCREENSHOT);
+ });
+ it("should migrate pinned favicon data without getting favicons again", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+ await feed.getLinksWithDefaults();
+ const { callCount } =
+ fakeNewTabUtils.activityStreamProvider._addFavicons;
+ feed.pinnedCache.expire();
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.callCount(
+ fakeNewTabUtils.activityStreamProvider._addFavicons,
+ callCount
+ );
+ assert.propertyVal(result[0], "favicon", FAKE_FAVICON);
+ assert.propertyVal(result[0], "faviconSize", FAKE_FAVICON_SIZE);
+ });
+ it("should not expose internal link properties", async () => {
+ const result = await feed.getLinksWithDefaults();
+
+ const internal = Object.keys(result[0]).filter(key =>
+ key.startsWith("__")
+ );
+ assert.equal(internal.join(""), "");
+ });
+ it("should copy the screenshot of the frecent site if pinned site doesn't have customScreenshotURL", async () => {
+ links = [{ url: "https://foo.com/", screenshot: "screenshot" }];
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.equal(result[0].screenshot, links[0].screenshot);
+ });
+ it("should not copy the frecent screenshot if customScreenshotURL is set", async () => {
+ links = [{ url: "https://foo.com/", screenshot: "screenshot" }];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com/", customScreenshotURL: "custom" },
+ ];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.isUndefined(result[0].screenshot);
+ });
+ it("should keep the same screenshot if no frecent site is found", async () => {
+ links = [];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com/", screenshot: "custom" },
+ ];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.equal(result[0].screenshot, "custom");
+ });
+ it("should not overwrite pinned site screenshot", async () => {
+ links = [{ url: "https://foo.com/", screenshot: "foo" }];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com/", screenshot: "bar" },
+ ];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.equal(result[0].screenshot, "bar");
+ });
+ it("should not set searchTopSite from frecent site", async () => {
+ links = [
+ {
+ url: "https://foo.com/",
+ searchTopSite: true,
+ screenshot: "screenshot",
+ },
+ ];
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.propertyVal(result[0], "searchTopSite", false);
+ // But it should copy over other properties
+ assert.propertyVal(result[0], "screenshot", "screenshot");
+ });
+ describe("concurrency", () => {
+ beforeEach(() => {
+ stubFaviconsToUseScreenshots();
+ fakeScreenshot.getScreenshotForURL = sandbox
+ .stub()
+ .resolves(FAKE_SCREENSHOT);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ const getTwice = () =>
+ Promise.all([
+ feed.getLinksWithDefaults(),
+ feed.getLinksWithDefaults(),
+ ]);
+
+ it("should call the backing data once", async () => {
+ await getTwice();
+
+ assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites);
+ });
+ it("should get screenshots once per link", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ await getTwice();
+
+ assert.callCount(
+ fakeScreenshot.getScreenshotForURL,
+ FAKE_LINKS.length
+ );
+ });
+ it("should dispatch once per link screenshot fetched", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ feed._requestRichIcon = sinon.stub();
+ await getTwice();
+
+ assert.callCount(feed.store.dispatch, FAKE_LINKS.length);
+ });
+ });
+ });
+ describe("deduping", () => {
+ beforeEach(() => {
+ ({ TopSitesFeed, DEFAULT_TOP_SITES } = injector({
+ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
+ "common/Reducers.jsm": {
+ insertPinned,
+ TOP_SITES_DEFAULT_ROWS,
+ TOP_SITES_MAX_SITES_PER_ROW,
+ },
+ "lib/Screenshots.jsm": { Screenshots: fakeScreenshot },
+ }));
+ sandbox.stub(global.Services.eTLD, "getPublicSuffix").returns("com");
+ feed = Object.assign(new TopSitesFeed(), { store: feed.store });
+ });
+ it("should not dedupe pinned sites", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://developer.mozilla.org/en-US/docs/Web" },
+ { url: "https://developer.mozilla.org/en-US/docs/Learn" },
+ ];
+
+ const sites = await feed.getLinksWithDefaults();
+
+ assert.lengthOf(sites, 2 * TOP_SITES_MAX_SITES_PER_ROW);
+ assert.equal(sites[0].url, fakeNewTabUtils.pinnedLinks.links[0].url);
+ assert.equal(sites[1].url, fakeNewTabUtils.pinnedLinks.links[1].url);
+ assert.equal(sites[0].hostname, sites[1].hostname);
+ });
+ it("should prefer pinned sites over links", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://developer.mozilla.org/en-US/docs/Web" },
+ { url: "https://developer.mozilla.org/en-US/docs/Learn" },
+ ];
+ // These will be the frecent results.
+ links = [
+ { frecency: FAKE_FRECENCY, url: "https://developer.mozilla.org/" },
+ { frecency: FAKE_FRECENCY, url: "https://www.mozilla.org/" },
+ ];
+
+ const sites = await feed.getLinksWithDefaults();
+
+ // Expecting 3 links where there's 2 pinned and 1 www.mozilla.org, so
+ // the frecent with matching hostname as pinned is removed.
+ assert.lengthOf(sites, 3);
+ assert.equal(sites[0].url, fakeNewTabUtils.pinnedLinks.links[0].url);
+ assert.equal(sites[1].url, fakeNewTabUtils.pinnedLinks.links[1].url);
+ assert.equal(sites[2].url, links[1].url);
+ });
+ it("should return sites that have a title", async () => {
+ // Simulate a pinned link with no title.
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://github.com/mozilla/activity-stream" },
+ ];
+
+ const sites = await feed.getLinksWithDefaults();
+
+ for (const site of sites) {
+ assert.isDefined(site.hostname);
+ }
+ });
+ it("should check against null entries", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [null];
+
+ await feed.getLinksWithDefaults();
+ });
+ });
+ it("should call _fetchIcon for each link", async () => {
+ sinon.spy(feed, "_fetchIcon");
+
+ const results = await feed.getLinksWithDefaults();
+
+ assert.callCount(feed._fetchIcon, results.length);
+ results.forEach(link => {
+ assert.calledWith(feed._fetchIcon, link);
+ });
+ });
+ it("should call _fetchScreenshot when customScreenshotURL is set", async () => {
+ links = [];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com", customScreenshotURL: "custom" },
+ ];
+ sinon.stub(feed, "_fetchScreenshot");
+
+ await feed.getLinksWithDefaults();
+
+ assert.calledWith(feed._fetchScreenshot, sinon.match.object, "custom");
+ });
+ describe("discoverystream", () => {
+ let makeStreamData = index => ({
+ layout: [
+ {
+ components: [
+ {
+ placement: {
+ name: "sponsored-topsites",
+ },
+ spocs: {
+ positions: [{ index }],
+ },
+ },
+ ],
+ },
+ ],
+ spocs: {
+ data: {
+ "sponsored-topsites": {
+ items: [{ title: "test spoc", url: "https://test-spoc.com" }],
+ },
+ },
+ },
+ });
+ it("should add a sponsored topsite from discoverystream to all the valid indices", async () => {
+ for (let i = 0; i < FAKE_LINKS.length; i++) {
+ feed.store.state.DiscoveryStream = makeStreamData(i);
+ const result = await feed.getLinksWithDefaults();
+ const link = result[i];
+
+ assert.equal(link.type, "SPOC");
+ assert.equal(link.title, "test spoc");
+ assert.equal(link.sponsored_position, i + 1);
+ assert.equal(link.hostname, "test-spoc");
+ assert.equal(link.url, "https://test-spoc.com");
+ }
+ });
+ });
+ });
+ describe("#init", () => {
+ it("should call refresh (broadcast:true)", async () => {
+ sandbox.stub(feed, "refresh");
+
+ await feed.init();
+
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, {
+ broadcast: true,
+ isStartup: true,
+ });
+ });
+ it("should initialise the storage", async () => {
+ await feed.init();
+
+ assert.calledOnce(feed.store.dbStorage.getDbTable);
+ assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs");
+ });
+ it("should call onUpdate to set up Nimbus update listener", async () => {
+ await feed.init();
+
+ assert.calledOnce(fakeNimbusFeatures.newtab.onUpdate);
+ });
+ });
+ describe("#refresh", () => {
+ beforeEach(() => {
+ sandbox.stub(feed, "_fetchIcon");
+ feed._startedUp = true;
+ });
+ it("should wait for tippytop to initialize", async () => {
+ feed._tippyTopProvider.initialized = false;
+ sinon.stub(feed._tippyTopProvider, "init").resolves();
+
+ await feed.refresh();
+
+ assert.calledOnce(feed._tippyTopProvider.init);
+ });
+ it("should not init the tippyTopProvider if already initialized", async () => {
+ feed._tippyTopProvider.initialized = true;
+ sinon.stub(feed._tippyTopProvider, "init").resolves();
+
+ await feed.refresh();
+
+ assert.notCalled(feed._tippyTopProvider.init);
+ });
+ it("should broadcast TOP_SITES_UPDATED", async () => {
+ sinon.stub(feed, "getLinksWithDefaults").returns(Promise.resolve([]));
+
+ await feed.refresh({ broadcast: true });
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.TOP_SITES_UPDATED,
+ data: { links: [], pref: { collapsed: false } },
+ })
+ );
+ });
+ it("should dispatch an action with the links returned", async () => {
+ await feed.refresh({ broadcast: true });
+ const reference = links.map(site =>
+ Object.assign({}, site, {
+ hostname: shortURLStub(site),
+ typedBonus: true,
+ })
+ );
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.propertyVal(
+ feed.store.dispatch.firstCall.args[0],
+ "type",
+ at.TOP_SITES_UPDATED
+ );
+ assert.deepEqual(
+ feed.store.dispatch.firstCall.args[0].data.links,
+ reference
+ );
+ });
+ it("should handle empty slots in the resulting top sites array", async () => {
+ links = [FAKE_LINKS[0]];
+ fakeNewTabUtils.pinnedLinks.links = [
+ null,
+ null,
+ FAKE_LINKS[1],
+ null,
+ null,
+ null,
+ null,
+ null,
+ FAKE_LINKS[2],
+ ];
+ await feed.refresh({ broadcast: true });
+ assert.calledOnce(feed.store.dispatch);
+ });
+ it("should dispatch AlsoToPreloaded when broadcast is false", async () => {
+ sandbox.stub(feed, "getLinksWithDefaults").returns([]);
+ await feed.refresh({ broadcast: false });
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.AlsoToPreloaded({
+ type: at.TOP_SITES_UPDATED,
+ data: { links: [], pref: { collapsed: false } },
+ })
+ );
+ });
+ it("should not init storage if it is already initialized", async () => {
+ feed._storage.initialized = true;
+
+ await feed.refresh({ broadcast: false });
+
+ assert.notCalled(feed._storage.init);
+ });
+ it("should catch indexedDB errors", async () => {
+ feed._storage.get.throws(new Error());
+ globals.sandbox.spy(global.console, "error");
+
+ try {
+ await feed.refresh({ broadcast: false });
+ } catch (e) {
+ assert.fails();
+ }
+
+ assert.calledOnce(console.error);
+ });
+ });
+ describe("#updateSectionPrefs", () => {
+ it("should call updateSectionPrefs on UPDATE_SECTION_PREFS", () => {
+ sandbox.stub(feed, "updateSectionPrefs");
+
+ feed.onAction({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: "topsites" },
+ });
+
+ assert.calledOnce(feed.updateSectionPrefs);
+ });
+ it("should dispatch TOP_SITES_PREFS_UPDATED", async () => {
+ await feed.updateSectionPrefs({ collapsed: true });
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.TOP_SITES_PREFS_UPDATED,
+ data: { pref: { collapsed: true } },
+ })
+ );
+ });
+ });
+ describe("#getScreenshotPreview", () => {
+ it("should dispatch preview if request is succesful", async () => {
+ await feed.getScreenshotPreview("custom", 1234);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.OnlyToOneContent(
+ {
+ data: { preview: FAKE_SCREENSHOT, url: "custom" },
+ type: at.PREVIEW_RESPONSE,
+ },
+ 1234
+ )
+ );
+ });
+ it("should return empty string if request fails", async () => {
+ fakeScreenshot.getScreenshotForURL = sandbox
+ .stub()
+ .returns(Promise.resolve(null));
+ await feed.getScreenshotPreview("custom", 1234);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.OnlyToOneContent(
+ {
+ data: { preview: "", url: "custom" },
+ type: at.PREVIEW_RESPONSE,
+ },
+ 1234
+ )
+ );
+ });
+ });
+ describe("#_fetchIcon", () => {
+ it("should reuse screenshot on the link", () => {
+ const link = { screenshot: "reuse.png" };
+
+ feed._fetchIcon(link);
+
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ assert.propertyVal(link, "screenshot", "reuse.png");
+ });
+ it("should reuse existing fetching screenshot on the link", async () => {
+ const link = {
+ __sharedCache: { fetchingScreenshot: Promise.resolve("fetching.png") },
+ };
+
+ await feed._fetchIcon(link);
+
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ });
+ it("should get a screenshot if the link is missing it", () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ feed._fetchIcon(Object.assign({ __sharedCache: {} }, FAKE_LINKS[0]));
+
+ assert.calledOnce(fakeScreenshot.getScreenshotForURL);
+ assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_LINKS[0].url);
+ });
+ it("should not get a screenshot if the link is missing it but top sites aren't shown", () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = false;
+ feed._fetchIcon(Object.assign({ __sharedCache: {} }, FAKE_LINKS[0]));
+
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ });
+ it("should update the link's cache with a screenshot", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ const updateLink = sandbox.stub();
+ const link = { __sharedCache: { updateLink } };
+
+ await feed._fetchIcon(link);
+
+ assert.calledOnce(updateLink);
+ assert.calledWith(updateLink, "screenshot", FAKE_SCREENSHOT);
+ });
+ it("should skip getting a screenshot if there is a tippy top icon", () => {
+ feed._tippyTopProvider.processSite = site => {
+ site.tippyTopIcon = "icon.png";
+ site.backgroundColor = "#fff";
+ return site;
+ };
+ const link = { url: "example.com" };
+ feed._fetchIcon(link);
+ assert.propertyVal(link, "tippyTopIcon", "icon.png");
+ assert.notProperty(link, "screenshot");
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ });
+ it("should skip getting a screenshot if there is an icon of size greater than 96x96 and no tippy top", () => {
+ const link = {
+ url: "foo.com",
+ favicon: "data:foo",
+ faviconSize: 196,
+ };
+ feed._fetchIcon(link);
+ assert.notProperty(link, "tippyTopIcon");
+ assert.notProperty(link, "screenshot");
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ });
+ it("should use the link's rich icon even if there's a tippy top", () => {
+ feed._tippyTopProvider.processSite = site => {
+ site.tippyTopIcon = "icon.png";
+ site.backgroundColor = "#fff";
+ return site;
+ };
+ const link = {
+ url: "foo.com",
+ favicon: "data:foo",
+ faviconSize: 196,
+ };
+ feed._fetchIcon(link);
+ assert.notProperty(link, "tippyTopIcon");
+ });
+ });
+ describe("#_fetchScreenshot", () => {
+ it("should call maybeCacheScreenshot", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ const updateLink = sinon.stub();
+ const link = {
+ customScreenshotURL: "custom",
+ __sharedCache: { updateLink },
+ };
+ await feed._fetchScreenshot(link, "custom");
+
+ assert.calledOnce(fakeScreenshot.maybeCacheScreenshot);
+ assert.calledWithExactly(
+ fakeScreenshot.maybeCacheScreenshot,
+ link,
+ link.customScreenshotURL,
+ "screenshot",
+ sinon.match.func
+ );
+ });
+ it("should not call maybeCacheScreenshot if screenshot is set", async () => {
+ const updateLink = sinon.stub();
+ const link = {
+ customScreenshotURL: "custom",
+ __sharedCache: { updateLink },
+ screenshot: true,
+ };
+ await feed._fetchScreenshot(link, "custom");
+
+ assert.notCalled(fakeScreenshot.maybeCacheScreenshot);
+ });
+ });
+ describe("#onAction", () => {
+ it("should call getScreenshotPreview on PREVIEW_REQUEST", () => {
+ sandbox.stub(feed, "getScreenshotPreview");
+
+ feed.onAction({
+ type: at.PREVIEW_REQUEST,
+ data: { url: "foo" },
+ meta: { fromTarget: 1234 },
+ });
+
+ assert.calledOnce(feed.getScreenshotPreview);
+ assert.calledWithExactly(feed.getScreenshotPreview, "foo", 1234);
+ });
+ it("should refresh on SYSTEM_TICK", async () => {
+ sandbox.stub(feed, "refresh");
+
+ feed.onAction({ type: at.SYSTEM_TICK });
+
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: false });
+ });
+ it("should call with correct parameters on TOP_SITES_PIN", () => {
+ const pinAction = {
+ type: at.TOP_SITES_PIN,
+ data: { site: { url: "foo.com" }, index: 7 },
+ };
+ feed.onAction(pinAction);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ pinAction.data.site,
+ pinAction.data.index
+ );
+ });
+ it("should call pin on TOP_SITES_PIN", () => {
+ sinon.stub(feed, "pin");
+ const pinExistingAction = {
+ type: at.TOP_SITES_PIN,
+ data: { site: FAKE_LINKS[4], index: 4 },
+ };
+
+ feed.onAction(pinExistingAction);
+
+ assert.calledOnce(feed.pin);
+ });
+ it("should trigger refresh on TOP_SITES_PIN", async () => {
+ sinon.stub(feed, "refresh");
+ const pinExistingAction = {
+ type: at.TOP_SITES_PIN,
+ data: { site: FAKE_LINKS[4], index: 4 },
+ };
+
+ await feed.pin(pinExistingAction);
+
+ assert.calledOnce(feed.refresh);
+ });
+ it("should unblock a previously blocked top site if we are now adding it manually via 'Add a Top Site' option", async () => {
+ const pinAction = {
+ type: at.TOP_SITES_PIN,
+ data: { site: { url: "foo.com" }, index: -1 },
+ };
+ feed.onAction(pinAction);
+ assert.calledWith(fakeNewTabUtils.blockedLinks.unblock, {
+ url: pinAction.data.site.url,
+ });
+ });
+ it("should call insert on TOP_SITES_INSERT", async () => {
+ sinon.stub(feed, "insert");
+ const addAction = {
+ type: at.TOP_SITES_INSERT,
+ data: { site: { url: "foo.com" } },
+ };
+
+ feed.onAction(addAction);
+
+ assert.calledOnce(feed.insert);
+ });
+ it("should trigger refresh on TOP_SITES_INSERT", async () => {
+ sinon.stub(feed, "refresh");
+ const addAction = {
+ type: at.TOP_SITES_INSERT,
+ data: { site: { url: "foo.com" } },
+ };
+
+ await feed.insert(addAction);
+
+ assert.calledOnce(feed.refresh);
+ });
+ it("should call unpin with correct parameters on TOP_SITES_UNPIN", () => {
+ fakeNewTabUtils.pinnedLinks.links = [
+ null,
+ null,
+ { url: "foo.com" },
+ null,
+ null,
+ null,
+ null,
+ null,
+ FAKE_LINKS[0],
+ ];
+ const unpinAction = {
+ type: at.TOP_SITES_UNPIN,
+ data: { site: { url: "foo.com" } },
+ };
+ feed.onAction(unpinAction);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.unpin,
+ unpinAction.data.site
+ );
+ });
+ it("should call refresh without a target if we clear history with PLACES_HISTORY_CLEARED", () => {
+ sandbox.stub(feed, "refresh");
+
+ feed.onAction({ type: at.PLACES_HISTORY_CLEARED });
+
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: true });
+ });
+ it("should call refresh without a target if we remove a Topsite from history", () => {
+ sandbox.stub(feed, "refresh");
+
+ feed.onAction({ type: at.PLACES_LINKS_DELETED });
+
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: true });
+ });
+ it("should still dispatch an action even if there's no target provided", async () => {
+ sandbox.stub(feed, "_fetchIcon");
+ feed._startedUp = true;
+ await feed.refresh({ broadcast: true });
+ assert.calledOnce(feed.store.dispatch);
+ assert.propertyVal(
+ feed.store.dispatch.firstCall.args[0],
+ "type",
+ at.TOP_SITES_UPDATED
+ );
+ });
+ it("should call init on INIT action", async () => {
+ sinon.stub(feed, "init");
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(feed.init);
+ });
+ it("should call refresh on PLACES_LINK_BLOCKED action", async () => {
+ sinon.stub(feed, "refresh");
+ await feed.onAction({ type: at.PLACES_LINK_BLOCKED });
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: true });
+ });
+ it("should call refresh on PLACES_LINKS_CHANGED action", async () => {
+ sinon.stub(feed, "refresh");
+ await feed.onAction({ type: at.PLACES_LINKS_CHANGED });
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: false });
+ });
+ it("should call pin with correct args on TOP_SITES_INSERT without an index specified", () => {
+ const addAction = {
+ type: at.TOP_SITES_INSERT,
+ data: { site: { url: "foo.bar", label: "foo" } },
+ };
+ feed.onAction(addAction);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ addAction.data.site,
+ 0
+ );
+ });
+ it("should call pin with correct args on TOP_SITES_INSERT", () => {
+ const dropAction = {
+ type: at.TOP_SITES_INSERT,
+ data: { site: { url: "foo.bar", label: "foo" }, index: 3 },
+ };
+ feed.onAction(dropAction);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ dropAction.data.site,
+ 3
+ );
+ });
+ it("should remove the expiration filter on UNINIT", () => {
+ feed.onAction({ type: "UNINIT" });
+
+ assert.calledOnce(fakePageThumbs.removeExpirationFilter);
+ });
+ it("should call updatePinnedSearchShortcuts on UPDATE_PINNED_SEARCH_SHORTCUTS action", async () => {
+ sinon.stub(feed, "updatePinnedSearchShortcuts");
+ const addedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ ];
+ await feed.onAction({
+ type: at.UPDATE_PINNED_SEARCH_SHORTCUTS,
+ data: { addedShortcuts },
+ });
+ assert.calledOnce(feed.updatePinnedSearchShortcuts);
+ });
+ it("should refresh from Contile on SHOW_SPONSORED_PREF if Contile is enabled", () => {
+ sandbox.spy(feed._contile, "refresh");
+ const prefChangeAction = {
+ type: at.PREF_CHANGED,
+ data: { name: SHOW_SPONSORED_PREF },
+ };
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+ feed.onAction(prefChangeAction);
+
+ assert.calledOnce(feed._contile.refresh);
+ });
+ it("should not refresh from Contile on SHOW_SPONSORED_PREF if Contile is disabled", () => {
+ sandbox.spy(feed._contile, "refresh");
+ const prefChangeAction = {
+ type: at.PREF_CHANGED,
+ data: { name: SHOW_SPONSORED_PREF },
+ };
+ fakeNimbusFeatures.newtab.getVariable.returns(false);
+ feed.onAction(prefChangeAction);
+
+ assert.notCalled(feed._contile.refresh);
+ });
+ it("should reset Contile cache prefs when SHOW_SPONSORED_PREF is false", () => {
+ Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]");
+ Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, 15 * 60 * 1000);
+ Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_PREF, Date.now());
+
+ sandbox.spy(feed._contile, "refresh");
+ const prefChangeAction = {
+ type: at.PREF_CHANGED,
+ data: { name: SHOW_SPONSORED_PREF, value: false },
+ };
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+ feed.onAction(prefChangeAction);
+
+ assert.calledOnce(feed._contile.refresh);
+
+ // cached pref values should have reset
+ assert.isUndefined(Services.prefs.getStringPref(CONTILE_CACHE_PREF));
+ assert.isUndefined(
+ Services.prefs.getIntPref(CONTILE_CACHE_LAST_FETCH_PREF)
+ );
+ assert.isUndefined(
+ Services.prefs.getIntPref(CONTILE_CACHE_VALID_FOR_PREF)
+ );
+ });
+ });
+ describe("#add", () => {
+ it("should pin site in first slot of empty pinned list", () => {
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ });
+ it("should pin site in first slot of pinned list with empty first slot", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ });
+ it("should move a pinned site in first slot to the next slot: part 1", () => {
+ const site1 = { url: "example.com" };
+ fakeNewTabUtils.pinnedLinks.links = [site1];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1);
+ });
+ it("should move a pinned site in first slot to the next slot: part 2", () => {
+ const site1 = { url: "example.com" };
+ const site2 = { url: "example.org" };
+ fakeNewTabUtils.pinnedLinks.links = [site1, null, site2];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1);
+ });
+ it("should unpin the last site if all slots are already pinned", () => {
+ const site1 = { url: "example.com" };
+ const site2 = { url: "example.org" };
+ const site3 = { url: "example.net" };
+ const site4 = { url: "example.biz" };
+ const site5 = { url: "example.info" };
+ const site6 = { url: "example.news" };
+ const site7 = { url: "example.lol" };
+ const site8 = { url: "example.golf" };
+ fakeNewTabUtils.pinnedLinks.links = [
+ site1,
+ site2,
+ site3,
+ site4,
+ site5,
+ site6,
+ site7,
+ site8,
+ ];
+ feed.store.state.Prefs.values.topSitesRows = 1;
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.equal(fakeNewTabUtils.pinnedLinks.pin.callCount, 8);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 2);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site3, 3);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site4, 4);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site5, 5);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site6, 6);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site7, 7);
+ });
+ });
+ describe("#pin", () => {
+ it("should pin site in specified slot empty pinned list", async () => {
+ const site = {
+ url: "foo.bar",
+ label: "foo",
+ customScreenshotURL: "screenshot",
+ };
+ await feed.pin({ data: { index: 2, site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ });
+ it("should lookup the link object to update the custom screenshot", async () => {
+ const site = {
+ url: "foo.bar",
+ label: "foo",
+ customScreenshotURL: "screenshot",
+ };
+ sandbox.spy(feed.pinnedCache, "request");
+
+ await feed.pin({ data: { index: 2, site } });
+
+ assert.calledOnce(feed.pinnedCache.request);
+ });
+ it("should lookup the link object to update the custom screenshot", async () => {
+ const site = { url: "foo.bar", label: "foo", customScreenshotURL: null };
+ sandbox.spy(feed.pinnedCache, "request");
+
+ await feed.pin({ data: { index: 2, site } });
+
+ assert.calledOnce(feed.pinnedCache.request);
+ });
+ it("should not do a link object lookup if custom screenshot field is not set", async () => {
+ const site = { url: "foo.bar", label: "foo" };
+ sandbox.spy(feed.pinnedCache, "request");
+
+ await feed.pin({ data: { index: 2, site } });
+
+ assert.notCalled(feed.pinnedCache.request);
+ });
+ it("should pin site in specified slot of pinned list that is free", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.pin({ data: { index: 2, site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ });
+ it("should save the searchTopSite attribute if set", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo", searchTopSite: true };
+ feed.pin({ data: { index: 2, site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.propertyVal(
+ fakeNewTabUtils.pinnedLinks.pin.firstCall.args[0],
+ "searchTopSite",
+ true
+ );
+ });
+ it("should NOT move a pinned site in specified slot to the next slot", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.pin({ data: { index: 2, site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ });
+ it("should properly update LinksCache object properties between migrations", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+
+ let pinnedLinks = await feed.pinnedCache.request();
+ assert.equal(pinnedLinks.length, 1);
+ feed.pinnedCache.expire();
+ pinnedLinks[0].__sharedCache.updateLink("screenshot", "foo");
+
+ pinnedLinks = await feed.pinnedCache.request();
+ assert.propertyVal(pinnedLinks[0], "screenshot", "foo");
+
+ // Force cache expiration in order to trigger a migration of objects
+ feed.pinnedCache.expire();
+ pinnedLinks[0].__sharedCache.updateLink("screenshot", "bar");
+
+ pinnedLinks = await feed.pinnedCache.request();
+ assert.propertyVal(pinnedLinks[0], "screenshot", "bar");
+ });
+ it("should call insert if index < 0", () => {
+ const site = { url: "foo.bar", label: "foo" };
+ const action = { data: { index: -1, site } };
+
+ sandbox.spy(feed, "insert");
+ feed.pin(action);
+
+ assert.calledOnce(feed.insert);
+ assert.calledWithExactly(feed.insert, action);
+ });
+ it("should not call insert if index == 0", () => {
+ const site = { url: "foo.bar", label: "foo" };
+ const action = { data: { index: 0, site } };
+
+ sandbox.spy(feed, "insert");
+ feed.pin(action);
+
+ assert.notCalled(feed.insert);
+ });
+ });
+ describe("clearLinkCustomScreenshot", () => {
+ it("should remove cached screenshot if custom url changes", async () => {
+ const stub = sandbox.stub();
+ sandbox.stub(feed.pinnedCache, "request").returns(
+ Promise.resolve([
+ {
+ url: "foo",
+ customScreenshotURL: "old_screenshot",
+ __sharedCache: { updateLink: stub },
+ },
+ ])
+ );
+
+ await feed._clearLinkCustomScreenshot({
+ url: "foo",
+ customScreenshotURL: "new_screenshot",
+ });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, "screenshot", undefined);
+ });
+ it("should remove cached screenshot if custom url is removed", async () => {
+ const stub = sandbox.stub();
+ sandbox.stub(feed.pinnedCache, "request").returns(
+ Promise.resolve([
+ {
+ url: "foo",
+ customScreenshotURL: "old_screenshot",
+ __sharedCache: { updateLink: stub },
+ },
+ ])
+ );
+
+ await feed._clearLinkCustomScreenshot({
+ url: "foo",
+ customScreenshotURL: "new_screenshot",
+ });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, "screenshot", undefined);
+ });
+ });
+ describe("#drop", () => {
+ it("should correctly handle different index values", () => {
+ let index = -1;
+ const site = { url: "foo.bar", label: "foo" };
+ const action = { data: { index, site } };
+
+ feed.insert(action);
+
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+
+ index = undefined;
+ feed.insert(action);
+
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ });
+ it("should pin site in specified slot that is free", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { index: 2, site, draggedFromIndex: 0 } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ });
+ it("should move a pinned site in specified slot to the next slot", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { index: 2, site, draggedFromIndex: 3 } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ { url: "example.com" },
+ 3
+ );
+ });
+ it("should move pinned sites in the direction of the dragged site", () => {
+ const site1 = { url: "foo.bar", label: "foo" };
+ const site2 = { url: "example.com", label: "example" };
+ fakeNewTabUtils.pinnedLinks.links = [null, null, site2];
+ feed.insert({ data: { index: 2, site: site1, draggedFromIndex: 0 } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 2);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 1);
+ fakeNewTabUtils.pinnedLinks.pin.resetHistory();
+ feed.insert({ data: { index: 2, site: site1, draggedFromIndex: 5 } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 2);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 3);
+ });
+ it("should not insert past the visible top sites", () => {
+ const site1 = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { index: 42, site: site1, draggedFromIndex: 0 } });
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.pin);
+ });
+ });
+ describe("integration", () => {
+ let resolvers = [];
+ beforeEach(() => {
+ feed.store.dispatch = sandbox.stub().callsFake(() => {
+ resolvers.shift()();
+ });
+ feed._startedUp = true;
+ sandbox.stub(feed, "_fetchScreenshot");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ const forDispatch = action =>
+ new Promise(resolve => {
+ resolvers.push(resolve);
+ feed.onAction(action);
+ });
+
+ it("should add a pinned site and remove it", async () => {
+ feed._requestRichIcon = sinon.stub();
+ const url = "https://pin.me";
+ fakeNewTabUtils.pinnedLinks.pin = sandbox.stub().callsFake(link => {
+ fakeNewTabUtils.pinnedLinks.links.push(link);
+ });
+
+ await forDispatch({ type: at.TOP_SITES_INSERT, data: { site: { url } } });
+ fakeNewTabUtils.pinnedLinks.links.pop();
+ await forDispatch({ type: at.PLACES_LINK_BLOCKED });
+
+ assert.calledTwice(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].data.links[0].url,
+ url
+ );
+ assert.equal(
+ feed.store.dispatch.secondCall.args[0].data.links[0].url,
+ FAKE_LINKS[0].url
+ );
+ });
+ });
+
+ describe("improvesearch.noDefaultSearchTile experiment", () => {
+ const NO_DEFAULT_SEARCH_TILE_PREF = "improvesearch.noDefaultSearchTile";
+ beforeEach(() => {
+ global.Services.search.getDefault = async () => ({
+ identifier: "google",
+ searchForm: "google.com",
+ });
+ feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true;
+ });
+ it("should filter out alexa top 5 search from the default sites", async () => {
+ const TOP_5_TEST = [
+ "google.com",
+ "search.yahoo.com",
+ "yahoo.com",
+ "bing.com",
+ "ask.com",
+ "duckduckgo.com",
+ ];
+ links = [{ url: "amazon.com" }, ...TOP_5_TEST.map(url => ({ url }))];
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.include(urlsReturned, "amazon.com");
+ TOP_5_TEST.forEach(url => assert.notInclude(urlsReturned, url));
+ });
+ it("should not filter out alexa, default search from the query results if the experiment pref is off", async () => {
+ links = [
+ { url: "google.com" },
+ { url: "foo.com" },
+ { url: "duckduckgo" },
+ ];
+ feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = false;
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.include(urlsReturned, "google.com");
+ });
+ it("should filter out the current default search from the default sites", async () => {
+ feed._currentSearchHostname = "amazon";
+ feed.onAction({
+ type: at.PREFS_INITIAL_VALUES,
+ data: { "default.sites": "google.com,amazon.com" },
+ });
+ links = [{ url: "foo.com" }];
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.notInclude(urlsReturned, "amazon.com");
+ });
+ it("should not filter out current default search from pinned sites even if it matches the current default search", async () => {
+ links = [{ url: "foo.com" }];
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "google.com" }];
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.include(urlsReturned, "google.com");
+ });
+ it("should call refresh and set ._currentSearchHostname to the new engine hostname when the the default search engine has been set", () => {
+ sinon.stub(feed, "refresh");
+ sandbox
+ .stub(global.Services.search, "defaultEngine")
+ .value({ identifier: "ddg", searchForm: "duckduckgo.com" });
+ feed.observe(null, "browser-search-engine-modified", "engine-default");
+ assert.equal(feed._currentSearchHostname, "duckduckgo");
+ assert.calledOnce(feed.refresh);
+ });
+ it("should call refresh when the experiment pref has changed", () => {
+ sinon.stub(feed, "refresh");
+
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: true },
+ });
+ assert.calledOnce(feed.refresh);
+
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: false },
+ });
+ assert.calledTwice(feed.refresh);
+ });
+ });
+
+ describe("improvesearch.topSitesSearchShortcuts", () => {
+ beforeEach(() => {
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = true;
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF] =
+ "google,amazon";
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = "";
+ const searchEngines = [
+ { aliases: ["@google"] },
+ { aliases: ["@amazon"] },
+ ];
+ global.Services.search.getAppProvidedEngines = async () => searchEngines;
+ fakeNewTabUtils.pinnedLinks.pin = sinon
+ .stub()
+ .callsFake((site, index) => {
+ fakeNewTabUtils.pinnedLinks.links[index] = site;
+ });
+ });
+
+ it("should properly disable search improvements if the pref is off", async () => {
+ sandbox.stub(global.Services.prefs, "clearUserPref");
+ sandbox.spy(feed.pinnedCache, "expire");
+ sandbox.spy(feed, "refresh");
+
+ // an actual implementation of unpin (until we can get a mochitest for search improvements)
+ fakeNewTabUtils.pinnedLinks.unpin = sinon.stub().callsFake(site => {
+ let index = -1;
+ for (let i = 0; i < fakeNewTabUtils.pinnedLinks.links.length; i++) {
+ let link = fakeNewTabUtils.pinnedLinks.links[i];
+ if (link && link.url === site.url) {
+ index = i;
+ }
+ }
+ if (index > -1) {
+ fakeNewTabUtils.pinnedLinks.links[index] = null;
+ }
+ });
+
+ // ensure we've inserted search shorcuts + pin an additional site in space 4
+ await feed._maybeInsertSearchShortcuts(fakeNewTabUtils.pinnedLinks.links);
+ fakeNewTabUtils.pinnedLinks.pin({ url: "https://dontunpinme.com" }, 3);
+
+ // turn the experiment off
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: false },
+ });
+
+ // check we cleared the pref, expired the pinned cache, and refreshed the feed
+ assert.calledWith(
+ global.Services.prefs.clearUserPref,
+ `browser.newtabpage.activity-stream.${SEARCH_SHORTCUTS_HAVE_PINNED_PREF}`
+ );
+ assert.calledOnce(feed.pinnedCache.expire);
+ assert.calledWith(feed.refresh, { broadcast: true });
+
+ // check that the search shortcuts were removed from the list of pinned sites
+ const urlsReturned = fakeNewTabUtils.pinnedLinks.links
+ .filter(s => s)
+ .map(link => link.url);
+ assert.notInclude(urlsReturned, "https://amazon.com");
+ assert.notInclude(urlsReturned, "https://google.com");
+ assert.include(urlsReturned, "https://dontunpinme.com");
+
+ // check that the positions where the search shortcuts were null, and the additional pinned site is untouched in space 4
+ assert.equal(fakeNewTabUtils.pinnedLinks.links[0], null);
+ assert.equal(fakeNewTabUtils.pinnedLinks.links[1], null);
+ assert.equal(fakeNewTabUtils.pinnedLinks.links[2], undefined);
+ assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], {
+ url: "https://dontunpinme.com",
+ });
+ });
+
+ it("should updateCustomSearchShortcuts when experiment pref is turned on", async () => {
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false;
+ feed.updateCustomSearchShortcuts = sinon.spy();
+
+ // turn the experiment on
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: true },
+ });
+
+ assert.calledOnce(feed.updateCustomSearchShortcuts);
+ });
+
+ it("should filter out default top sites that match a hostname of a search shortcut if previously blocked", async () => {
+ feed.refreshDefaults("https://amazon.ca");
+ fakeNewTabUtils.blockedLinks.links = [{ url: "https://amazon.com" }];
+ fakeNewTabUtils.blockedLinks.isBlocked = site =>
+ fakeNewTabUtils.blockedLinks.links[0].url === site.url;
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.notInclude(urlsReturned, "https://amazon.ca");
+ });
+
+ it("should update frecent search topsite icon", async () => {
+ feed._tippyTopProvider.processSite = site => {
+ site.tippyTopIcon = "icon.png";
+ site.backgroundColor = "#fff";
+ return site;
+ };
+ links = [{ url: "google.com" }];
+
+ const urlsReturned = await feed.getLinksWithDefaults();
+
+ const defaultSearchTopsite = urlsReturned.find(
+ s => s.url === "google.com"
+ );
+ assert.propertyVal(defaultSearchTopsite, "searchTopSite", true);
+ assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png");
+ assert.equal(defaultSearchTopsite.backgroundColor, "#fff");
+ });
+ it("should update default search topsite icon", async () => {
+ feed._tippyTopProvider.processSite = site => {
+ site.tippyTopIcon = "icon.png";
+ site.backgroundColor = "#fff";
+ return site;
+ };
+ links = [{ url: "foo.com" }];
+ feed.onAction({
+ type: at.PREFS_INITIAL_VALUES,
+ data: { "default.sites": "google.com,amazon.com" },
+ });
+
+ const urlsReturned = await feed.getLinksWithDefaults();
+
+ const defaultSearchTopsite = urlsReturned.find(
+ s => s.url === "amazon.com"
+ );
+ assert.propertyVal(defaultSearchTopsite, "searchTopSite", true);
+ assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png");
+ assert.equal(defaultSearchTopsite.backgroundColor, "#fff");
+ });
+ it("should dispatch UPDATE_SEARCH_SHORTCUTS on updateCustomSearchShortcuts", async () => {
+ feed.store.state.Prefs.values["improvesearch.noDefaultSearchTile"] = true;
+ await feed.updateCustomSearchShortcuts();
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: {
+ searchShortcuts: [
+ {
+ keyword: "@google",
+ shortURL: "google",
+ url: "https://google.com",
+ },
+ {
+ keyword: "@amazon",
+ shortURL: "amazon",
+ url: "https://amazon.com",
+ },
+ ],
+ },
+ meta: {
+ from: "ActivityStream:Main",
+ to: "ActivityStream:Content",
+ isStartup: false,
+ },
+ type: "UPDATE_SEARCH_SHORTCUTS",
+ });
+ });
+
+ describe("_maybeInsertSearchShortcuts", () => {
+ beforeEach(() => {
+ // Default is one row
+ feed.store.state.Prefs.values.topSitesRows = TOP_SITES_DEFAULT_ROWS;
+ // Eight slots per row
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "" },
+ { url: "" },
+ { url: "" },
+ null,
+ { url: "" },
+ { url: "" },
+ null,
+ { url: "" },
+ ];
+ });
+
+ it("should be called on getLinksWithDefaults", async () => {
+ sandbox.spy(feed, "_maybeInsertSearchShortcuts");
+ await feed.getLinksWithDefaults();
+ assert.calledOnce(feed._maybeInsertSearchShortcuts);
+ });
+
+ it("should do nothing and return false if the experiment is disabled", async () => {
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false;
+ assert.isFalse(
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ )
+ );
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.pin);
+ });
+
+ it("should pin shortcuts in the correct order, into the available unpinned slots", async () => {
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ // The shouldPin pref is "google,amazon" so expect the shortcuts in that order
+ assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], {
+ url: "https://google.com",
+ searchTopSite: true,
+ label: "@google",
+ });
+ assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[6], {
+ url: "https://amazon.com",
+ searchTopSite: true,
+ label: "@amazon",
+ });
+ });
+
+ it("should not pin shortcuts for the current default search engine", async () => {
+ feed._currentSearchHostname = "google";
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], {
+ url: "https://amazon.com",
+ searchTopSite: true,
+ label: "@amazon",
+ });
+ });
+
+ it("should only pin the first shortcut if there's only one available slot", async () => {
+ fakeNewTabUtils.pinnedLinks.links[3] = { url: "" };
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ // The first item in the shouldPin pref is "google" so expect only Google to be pinned
+ assert.ok(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+ });
+
+ it("should pin none if there's no available slot", async () => {
+ fakeNewTabUtils.pinnedLinks.links[3] = { url: "" };
+ fakeNewTabUtils.pinnedLinks.links[6] = { url: "" };
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+ });
+
+ it("should not pin a shortcut if the corresponding search engine is not available", async () => {
+ // Make Amazon search engine unavailable
+ global.Services.search.getAppProvidedEngines = async () => [
+ { aliases: ["@google"] },
+ ];
+ fakeNewTabUtils.pinnedLinks.links.fill(null);
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+ });
+
+ it("should not pin a search shortcut if it's been pinned before", async () => {
+ fakeNewTabUtils.pinnedLinks.links.fill(null);
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] =
+ "google,amazon";
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+
+ fakeNewTabUtils.pinnedLinks.links.fill(null);
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] =
+ "amazon";
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.ok(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+
+ fakeNewTabUtils.pinnedLinks.links.fill(null);
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] =
+ "google";
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.ok(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+ });
+
+ it("should record the insertion of a search shortcut", async () => {
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = "";
+ // Fill up one slot, so there's only one left - to be filled by Google
+ fakeNewTabUtils.pinnedLinks.links[3] = { url: "" };
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.calledWithExactly(feed.store.dispatch, {
+ data: { name: SEARCH_SHORTCUTS_HAVE_PINNED_PREF, value: "google" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ });
+ });
+
+ describe("updatePinnedSearchShortcuts", () => {
+ it("should unpin a shortcut in deletedShortcuts", () => {
+ const deletedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ ];
+ const addedShortcuts = [];
+ fakeNewTabUtils.pinnedLinks.links = [
+ null,
+ null,
+ {
+ url: "https://amazon.com",
+ searchVendor: "amazon",
+ label: "amazon",
+ searchTopSite: true,
+ },
+ ];
+ feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.unpin, {
+ url: "https://google.com",
+ });
+ });
+
+ it("should pin a shortcut in addedShortcuts", () => {
+ const addedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ ];
+ const deletedShortcuts = [];
+ fakeNewTabUtils.pinnedLinks.links = [
+ null,
+ null,
+ {
+ url: "https://amazon.com",
+ searchVendor: "amazon",
+ label: "amazon",
+ searchTopSite: true,
+ },
+ ];
+ feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ {
+ label: "google",
+ searchTopSite: true,
+ searchVendor: "google",
+ url: "https://google.com",
+ },
+ 0
+ );
+ });
+
+ it("should pin and unpin in the same action", () => {
+ const addedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ {
+ url: "https://ebay.com",
+ searchVendor: "ebay",
+ label: "ebay",
+ searchTopSite: true,
+ },
+ ];
+ const deletedShortcuts = [
+ {
+ url: "https://amazon.com",
+ searchVendor: "amazon",
+ label: "amazon",
+ searchTopSite: true,
+ },
+ ];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com" },
+ {
+ url: "https://amazon.com",
+ searchVendor: "amazon",
+ label: "amazon",
+ searchTopSite: true,
+ },
+ ];
+ feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ });
+
+ it("should pin a shortcut in addedShortcuts even if pinnedLinks is full", () => {
+ const addedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ ];
+ const deletedShortcuts = [];
+ fakeNewTabUtils.pinnedLinks.links = FAKE_LINKS;
+ feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ { label: "google", searchTopSite: true, url: "https://google.com" },
+ 0
+ );
+ });
+ });
+
+ describe("#_attachTippyTopIconForSearchShortcut", () => {
+ beforeEach(() => {
+ feed._tippyTopProvider.processSite = site => {
+ if (site.url === "https://www.yandex.ru/") {
+ site.tippyTopIcon = "yandex-ru.png";
+ site.smallFavicon = "yandex-ru.ico";
+ } else if (
+ site.url === "https://www.yandex.com/" ||
+ site.url === "https://yandex.com"
+ ) {
+ site.tippyTopIcon = "yandex.png";
+ site.smallFavicon = "yandex.ico";
+ } else {
+ site.tippyTopIcon = "google.png";
+ site.smallFavicon = "google.ico";
+ }
+ return site;
+ };
+ });
+
+ it("should choose the -ru icons for Yandex search shortcut", async () => {
+ sandbox.stub(global.Services.search, "getEngineByAlias").resolves({
+ wrappedJSObject: { _searchForm: "https://www.yandex.ru/" },
+ });
+
+ const link = { url: "https://yandex.com" };
+ await feed._attachTippyTopIconForSearchShortcut(link, "@yandex");
+
+ assert.equal(link.tippyTopIcon, "yandex-ru.png");
+ assert.equal(link.smallFavicon, "yandex-ru.ico");
+ assert.equal(link.url, "https://yandex.com");
+ });
+
+ it("should choose -com icons for Yandex search shortcut", async () => {
+ sandbox.stub(global.Services.search, "getEngineByAlias").resolves({
+ wrappedJSObject: { _searchForm: "https://www.yandex.com/" },
+ });
+
+ const link = { url: "https://yandex.com" };
+ await feed._attachTippyTopIconForSearchShortcut(link, "@yandex");
+
+ assert.equal(link.tippyTopIcon, "yandex.png");
+ assert.equal(link.smallFavicon, "yandex.ico");
+ assert.equal(link.url, "https://yandex.com");
+ });
+
+ it("should use the -com icons if can't fetch the search form URL", async () => {
+ sandbox.stub(global.Services.search, "getEngineByAlias").resolves(null);
+
+ const link = { url: "https://yandex.com" };
+ await feed._attachTippyTopIconForSearchShortcut(link, "@yandex");
+
+ assert.equal(link.tippyTopIcon, "yandex.png");
+ assert.equal(link.smallFavicon, "yandex.ico");
+ assert.equal(link.url, "https://yandex.com");
+ });
+
+ it("should choose the correct icon for other non-yandex search shortcut", async () => {
+ sandbox.stub(global.Services.search, "getEngineByAlias").resolves({
+ wrappedJSObject: { _searchForm: "https://www.google.com/" },
+ });
+
+ const link = { url: "https://google.com" };
+ await feed._attachTippyTopIconForSearchShortcut(link, "@google");
+
+ assert.equal(link.tippyTopIcon, "google.png");
+ assert.equal(link.smallFavicon, "google.ico");
+ assert.equal(link.url, "https://google.com");
+ });
+ });
+
+ describe("#ContileIntegration", () => {
+ let getStringPrefStub;
+ let getIntPrefStub;
+ beforeEach(() => {
+ // Turn on sponsored TopSites for testing
+ feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true;
+ fetchStub = sandbox.stub();
+ globals.set("fetch", fetchStub);
+
+ getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
+ getStringPrefStub
+ .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF)
+ .returns(`["foo","bar"]`);
+
+ getIntPrefStub = sandbox.stub(global.Services.prefs, "getIntPref");
+
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+ sandbox.spy(global.Services.prefs, "setStringPref");
+ sandbox.spy(global.Services.prefs, "setIntPref");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should fetch sites from Contile", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://www.test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 2);
+ });
+
+ it("should fetch SOV (Share-of-Voice) settings from Contile", async () => {
+ const sov = {
+ name: "SOV-20230518215316",
+ allocations: [
+ {
+ position: 1,
+ allocation: [
+ {
+ partner: "foo",
+ percentage: 100,
+ },
+ {
+ partner: "bar",
+ percentage: 0,
+ },
+ ],
+ },
+ {
+ position: 2,
+ allocation: [
+ {
+ partner: "foo",
+ percentage: 80,
+ },
+ {
+ partner: "bar",
+ percentage: 20,
+ },
+ ],
+ },
+ ],
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ sov: btoa(JSON.stringify(sov)),
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://www.test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.deepEqual(feed._contile.sov, sov);
+ assert.equal(feed._contile.sites.length, 2);
+ });
+
+ it("should not fetch from Contile if it's not enabled", async () => {
+ fakeNimbusFeatures.newtab.getVariable.reset();
+ fakeNimbusFeatures.newtab.getVariable.returns(false);
+ const fetched = await feed._contile._fetchSites();
+
+ assert.notCalled(fetchStub);
+ assert.ok(!fetched);
+ assert.equal(feed._contile.sites.length, 0);
+ });
+
+ it("should still return two tiles when Contile provides more than 2 tiles and filtering results in more than 2 tiles", async () => {
+ fakeNimbusFeatures.newtab.getVariable.reset();
+ fakeNimbusFeatures.newtab.getVariable.onCall(0).returns(true);
+ fakeNimbusFeatures.newtab.getVariable.onCall(1).returns(true);
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ },
+ {
+ url: "https://bar.com",
+ image_url: "images/bar-com.png",
+ click_url: "https://www.bar-click.com",
+ impression_url: "https://www.bar-impression.com",
+ name: "bar",
+ },
+ {
+ url: "https://test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ {
+ url: "https://test2.com",
+ image_url: "images/test2-com.png",
+ click_url: "https://www.test2-click.com",
+ impression_url: "https://www.test2-impression.com",
+ name: "test2",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ // Both "foo" and "bar" should be filtered
+ assert.equal(feed._contile.sites.length, 2);
+ assert.equal(feed._contile.sites[0].url, "https://www.test.com");
+ assert.equal(feed._contile.sites[1].url, "https://test1.com");
+ });
+
+ it("should still return two tiles with replacement if the Nimbus variable was unset", async () => {
+ fakeNimbusFeatures.newtab.getVariable.reset();
+ fakeNimbusFeatures.newtab.getVariable.onCall(0).returns(true);
+ fakeNimbusFeatures.newtab.getVariable.onCall(1).returns(undefined);
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ },
+ {
+ url: "https://test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 2);
+ assert.equal(feed._contile.sites[0].url, "https://www.test.com");
+ assert.equal(feed._contile.sites[1].url, "https://test1.com");
+ });
+
+ it("should filter the blocked sponsors", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ },
+ {
+ url: "https://bar.com",
+ image_url: "images/bar-com.png",
+ click_url: "https://www.bar-click.com",
+ impression_url: "https://www.bar-impression.com",
+ name: "bar",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ // Both "foo" and "bar" should be filtered
+ assert.equal(feed._contile.sites.length, 1);
+ assert.equal(feed._contile.sites[0].url, "https://www.test.com");
+ });
+
+ it("should return false when Contile returns with error status and no values are stored in cache prefs", async () => {
+ fetchStub.resolves({
+ ok: false,
+ status: 500,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(!fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should return false when Contile returns with error status and cached tiles are expried", async () => {
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_VALID_FOR_PREF)
+ .returns(1000 * 60 * 15);
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_LAST_FETCH_PREF)
+ .returns(Date.now() - 1000 * 60 * 30);
+
+ fetchStub.resolves({
+ ok: false,
+ status: 500,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(!fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should handle invalid payload properly from Contile", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () =>
+ Promise.resolve({
+ unknown: [],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(!fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should handle empty payload properly from Contile", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should handle no content properly from Contile", async () => {
+ fetchStub.resolves({ ok: true, status: 204 });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(!fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should set Caching Prefs after a sucessful request", async () => {
+ const tiles = [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://www.test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ];
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles,
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+ assert.ok(fetched);
+ assert.calledOnce(Services.prefs.setStringPref);
+ assert.calledTwice(Services.prefs.setIntPref);
+
+ assert.calledWith(
+ Services.prefs.setStringPref,
+ CONTILE_CACHE_PREF,
+ JSON.stringify(tiles)
+ );
+ assert.calledWith(
+ Services.prefs.setIntPref,
+ CONTILE_CACHE_VALID_FOR_PREF,
+ 11322
+ );
+ });
+
+ it("should return cached valid tiles when Contile returns error status", async () => {
+ const tiles = [
+ {
+ url: "https://www.test-cached.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://www.test1-cached.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ];
+
+ getStringPrefStub
+ .withArgs(CONTILE_CACHE_PREF)
+ .returns(JSON.stringify(tiles));
+
+ // valid for 15 mins
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_VALID_FOR_PREF)
+ .returns(1000 * 60 * 15);
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_LAST_FETCH_PREF)
+ .returns(Date.now());
+
+ fetchStub.resolves({
+ status: 304,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 2);
+ assert.equal(feed._contile.sites[0].url, "https://www.test-cached.com");
+ assert.equal(feed._contile.sites[1].url, "https://www.test1-cached.com");
+ });
+
+ it("should not be successful when contile returns an error and no valid tiles are cached", async () => {
+ getStringPrefStub.withArgs(CONTILE_CACHE_PREF).returns("[]");
+
+ getIntPrefStub.withArgs(CONTILE_CACHE_VALID_FOR_PREF).returns(0);
+ getIntPrefStub.withArgs(CONTILE_CACHE_LAST_FETCH_PREF).returns(0);
+
+ fetchStub.resolves({
+ status: 500,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+ assert.ok(!fetched);
+ });
+
+ it("should return cached valid tiles filtering blocked tiles when Contile returns error status", async () => {
+ const tiles = [
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ },
+ {
+ url: "https://www.test1-cached.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ];
+ getStringPrefStub
+ .withArgs(CONTILE_CACHE_PREF)
+ .returns(JSON.stringify(tiles));
+
+ // valid for 15 mins
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_VALID_FOR_PREF)
+ .returns(1000 * 60 * 15);
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_LAST_FETCH_PREF)
+ .returns(Date.now());
+
+ fetchStub.resolves({
+ status: 304,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 1);
+ assert.equal(feed._contile.sites[0].url, "https://www.test1-cached.com");
+ });
+
+ it("should still return 3 tiles when nimbus variable overrides max num of sponsored contile tiles", async () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(3);
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ {
+ url: "https://test2.com",
+ image_url: "images/test2-com.png",
+ click_url: "https://www.test2-click.com",
+ impression_url: "https://www.test2-impression.com",
+ name: "test2",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 3);
+ assert.equal(feed._contile.sites[0].url, "https://www.test.com");
+ assert.equal(feed._contile.sites[1].url, "https://test1.com");
+ assert.equal(feed._contile.sites[2].url, "https://test2.com");
+ });
+ });
+
+ describe("#_mergeSponsoredLinks", () => {
+ let fakeSponsoredLinks;
+ let sov;
+ beforeEach(() => {
+ fakeSponsoredLinks = {
+ amp: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ partner: "amp",
+ sponsored_position: 1,
+ },
+ {
+ url: "https://www.test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ partner: "amp",
+ sponsored_position: 2,
+ },
+ {
+ url: "https://www.test2.com",
+ image_url: "images/test2-com.png",
+ click_url: "https://www.test2-click.com",
+ impression_url: "https://www.test2-impression.com",
+ name: "test2",
+ partner: "amp",
+ sponsored_position: 2,
+ },
+ ],
+ "moz-sales": [
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ partner: "moz-sales",
+ pos: 2,
+ },
+ ],
+ };
+
+ sov = {
+ name: "SOV-20230518215316",
+ allocations: [
+ {
+ position: 1,
+ allocation: [
+ {
+ partner: "amp",
+ percentage: 100,
+ },
+ {
+ partner: "moz-sales",
+ percentage: 0,
+ },
+ ],
+ },
+ {
+ position: 2,
+ allocation: [
+ {
+ partner: "amp",
+ percentage: 80,
+ },
+ {
+ partner: "moz-sales",
+ percentage: 20,
+ },
+ ],
+ },
+ ],
+ };
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should join sponsored links if the sov object is absent", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => null);
+
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat());
+ });
+
+ it("should join sponosred links if the SOV Nimbus variable is disabled", async () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(false);
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat());
+ });
+
+ it("should pick sponsored links based on sov configurations", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => sov);
+ fakeNimbusFeatures.pocketNewtab.getVariable.reset();
+ fakeNimbusFeatures.pocketNewtab.getVariable.onCall(0).returns(true);
+ fakeNimbusFeatures.pocketNewtab.getVariable.onCall(1).returns(undefined);
+ global.Sampling.ratioSample.onCall(0).resolves(0);
+ global.Sampling.ratioSample.onCall(1).resolves(1);
+
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.equal(sponsored.length, 2);
+ assert.equal(sponsored[0].partner, "amp");
+ assert.equal(sponsored[0].sponsored_position, 1);
+ assert.equal(sponsored[1].partner, "moz-sales");
+ assert.equal(sponsored[1].sponsored_position, 2);
+ assert.equal(sponsored[1].pos, 1);
+ });
+
+ it("should add remaining contile tiles when nimbus var contile max num sponsored is present", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => sov);
+ fakeNimbusFeatures.pocketNewtab.getVariable.reset();
+ fakeNimbusFeatures.pocketNewtab.getVariable.onCall(0).returns(true);
+ fakeNimbusFeatures.pocketNewtab.getVariable.onCall(1).returns(3);
+ global.Sampling.ratioSample.resolves(0);
+
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.equal(sponsored.length, 3);
+ });
+
+ it("should fall back to other partners if the chosen partner does not have any links", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => sov);
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(true);
+ global.Sampling.ratioSample.onCall(0).resolves(0);
+ global.Sampling.ratioSample.onCall(1).resolves(0);
+
+ fakeSponsoredLinks.amp = [];
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.equal(sponsored.length, 1);
+ assert.equal(sponsored[0].partner, "moz-sales");
+ assert.equal(sponsored[0].sponsored_position, 1);
+ assert.equal(sponsored[0].pos, 0);
+ });
+
+ it("should return an empty array if none of the partners have links", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => sov);
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(true);
+ global.Sampling.ratioSample.onCall(0).resolves(0);
+ global.Sampling.ratioSample.onCall(1).resolves(0);
+
+ fakeSponsoredLinks.amp = [];
+ fakeSponsoredLinks["moz-sales"] = [];
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.equal(sponsored.length, 0);
+ });
+ });
+
+ describe("#_readDefaults", () => {
+ beforeEach(() => {
+ // Turn on sponsored TopSites for testing
+ feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true;
+ fetchStub = sandbox.stub();
+ globals.set("fetch", fetchStub);
+ fetchStub.resolves({ ok: true, status: 204 });
+ sandbox
+ .stub(global.Services.prefs, "getBoolPref")
+ .withArgs(REMOTE_SETTING_DEFAULTS_PREF)
+ .returns(true);
+
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF)
+ .returns(`["foo","bar"]`);
+ sandbox.stub(global.Services.prefs, "prefIsLocked").returns(false);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should filter all blocked sponsored tiles from RemoteSettings when Contile is disabled", async () => {
+ sandbox.stub(feed, "_getRemoteConfig").resolves([
+ { url: "https://foo.com", title: "foo", sponsored_position: 1 },
+ { url: "https://bar.com", title: "bar", sponsored_position: 2 },
+ { url: "https://test.com", title: "test", sponsored_position: 3 },
+ ]);
+ fakeNimbusFeatures.newtab.getVariable.returns(false);
+ await feed._readDefaults();
+
+ assert.equal(DEFAULT_TOP_SITES.length, 1);
+ assert.equal(DEFAULT_TOP_SITES[0].label, "test");
+ });
+
+ it("should also filter all blocked sponsored tiles from RemoteSettings when Contile is enabled", async () => {
+ sandbox.stub(feed, "_getRemoteConfig").resolves([
+ { url: "https://foo.com", title: "foo", sponsored_position: 1 },
+ { url: "https://bar.com", title: "bar", sponsored_position: 2 },
+ { url: "https://test.com", title: "test", sponsored_position: 3 },
+ ]);
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+
+ await feed._readDefaults();
+
+ assert.equal(DEFAULT_TOP_SITES.length, 1);
+ assert.equal(DEFAULT_TOP_SITES[0].label, "test");
+ });
+
+ it("should not filter non-sponsored tiles from RemoteSettings", async () => {
+ sandbox.stub(feed, "_getRemoteConfig").resolves([
+ { url: "https://foo.com", title: "foo", sponsored_position: 1 },
+ { url: "https://bar.com", title: "bar", sponsored_position: 2 },
+ { url: "https://foo.com", title: "foo" },
+ ]);
+
+ await feed._readDefaults();
+
+ assert.equal(DEFAULT_TOP_SITES.length, 1);
+ assert.equal(DEFAULT_TOP_SITES[0].label, "foo");
+ });
+
+ it("should take the image from Contile if it's a hi-res one", async () => {
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+ sandbox.stub(feed, "_getRemoteConfig").resolves([]);
+
+ sandbox.stub(feed._contile, "sites").get(() => [
+ {
+ url: "https://test.com",
+ image_url: "https://images.test.com/test-com.png",
+ image_size: 192,
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://test1.com",
+ image_url: "https://images.test1.com/test1-com.png",
+ image_size: 32,
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ]);
+
+ await feed._readDefaults();
+
+ const [site1, site2] = DEFAULT_TOP_SITES;
+ assert.propertyVal(
+ site1,
+ "favicon",
+ "https://images.test.com/test-com.png"
+ );
+ assert.propertyVal(site1, "faviconSize", 192);
+
+ // Should not be taken as it's not hi-res
+ assert.isUndefined(site2.favicon);
+ assert.isUndefined(site2.faviconSize);
+ });
+ });
+
+ describe("#_nimbusChangeListener", () => {
+ it("should refresh on Nimbus feature updates reasons", () => {
+ sandbox.spy(feed._contile, "refresh");
+ feed._nimbusChangeListener(null, "experiment-updated");
+
+ assert.calledOnce(feed._contile.refresh);
+ });
+
+ it("should not refresh on Nimbus feature loaded reasons", () => {
+ sandbox.spy(feed._contile, "refresh");
+ feed._nimbusChangeListener(null, "feature-experiment-loaded");
+ feed._nimbusChangeListener(null, "feature-rollout-loaded");
+
+ assert.notCalled(feed._contile.refresh);
+ });
+ });
+
+ describe("#_maybeCapSponsoredLinks", () => {
+ let sponsoredLinks;
+
+ beforeEach(() => {
+ sponsoredLinks = [
+ {
+ url: "https://www.test.com",
+ name: "test",
+ sponsored_position: 1,
+ },
+ {
+ url: "https://www.test1.com",
+ name: "test1",
+ sponsored_position: 2,
+ },
+ {
+ url: "https://www.test2.com",
+ name: "test2",
+ sponsored_position: 3,
+ },
+ ];
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should fall back to the default if the Nimbus variable is unspecified", () => {
+ feed._maybeCapSponsoredLinks(sponsoredLinks);
+
+ assert.equal(sponsoredLinks.length, 2);
+ });
+ it("should cap the links if specified by the Nimbus variable", () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(1);
+
+ feed._maybeCapSponsoredLinks(sponsoredLinks);
+
+ assert.equal(sponsoredLinks.length, 1);
+ });
+ it("should leave all the links if the Nimbus variable is equal to what we have", () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(3);
+
+ feed._maybeCapSponsoredLinks(sponsoredLinks);
+
+ assert.equal(sponsoredLinks.length, 3);
+ });
+ it("should ignore caps if they are more than what we have", () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(10);
+
+ feed._maybeCapSponsoredLinks(sponsoredLinks);
+
+ assert.equal(sponsoredLinks.length, 3);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js b/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js
new file mode 100644
index 0000000000..f6560d7ab2
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js
@@ -0,0 +1,1903 @@
+import { FAKE_GLOBAL_PREFS, FakePrefs, GlobalOverrider } from "test/unit/utils";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import injector from "inject!lib/TopStoriesFeed.jsm";
+
+describe("Top Stories Feed", () => {
+ let TopStoriesFeed;
+ let STORIES_UPDATE_TIME;
+ let TOPICS_UPDATE_TIME;
+ let SECTION_ID;
+ let SPOC_IMPRESSION_TRACKING_PREF;
+ let REC_IMPRESSION_TRACKING_PREF;
+ let DEFAULT_RECS_EXPIRE_TIME;
+ let instance;
+ let clock;
+ let globals;
+ let sectionsManagerStub;
+ let shortURLStub;
+
+ const FAKE_OPTIONS = {
+ stories_endpoint: "https://somedomain.org/stories?key=$apiKey",
+ stories_referrer: "https://somedomain.org/referrer",
+ topics_endpoint: "https://somedomain.org/topics?key=$apiKey",
+ survey_link: "https://www.surveymonkey.com/r/newtabffx",
+ api_key_pref: "apiKeyPref",
+ provider_name: "test-provider",
+ provider_icon: "provider-icon",
+ provider_description: "provider_desc",
+ };
+
+ beforeEach(() => {
+ FAKE_GLOBAL_PREFS.set("apiKeyPref", "test-api-key");
+ FAKE_GLOBAL_PREFS.set(
+ "pocketCta",
+ JSON.stringify({
+ cta_button: "",
+ cta_text: "",
+ cta_url: "",
+ use_cta: false,
+ })
+ );
+
+ globals = new GlobalOverrider();
+ globals.set("PlacesUtils", { history: {} });
+ globals.set("pktApi", { isUserLoggedIn() {} });
+ clock = sinon.useFakeTimers();
+ shortURLStub = sinon.stub().callsFake(site => site.url);
+ sectionsManagerStub = {
+ onceInitialized: sinon.stub().callsFake(callback => callback()),
+ enableSection: sinon.spy(),
+ disableSection: sinon.spy(),
+ updateSection: sinon.spy(),
+ sections: new Map([["topstories", { options: FAKE_OPTIONS }]]),
+ };
+
+ ({
+ TopStoriesFeed,
+ STORIES_UPDATE_TIME,
+ TOPICS_UPDATE_TIME,
+ SECTION_ID,
+ SPOC_IMPRESSION_TRACKING_PREF,
+ REC_IMPRESSION_TRACKING_PREF,
+ DEFAULT_RECS_EXPIRE_TIME,
+ } = injector({
+ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
+ "lib/ShortURL.jsm": { shortURL: shortURLStub },
+ "lib/SectionsManager.jsm": { SectionsManager: sectionsManagerStub },
+ }));
+
+ instance = new TopStoriesFeed();
+ instance.store = {
+ getState() {
+ return {
+ Prefs: {
+ values: {
+ showSponsored: true,
+ "feeds.section.topstories": true,
+ },
+ },
+ };
+ },
+ dispatch: sinon.spy(),
+ };
+ instance.storiesLastUpdated = 0;
+ instance.topicsLastUpdated = 0;
+ });
+ afterEach(() => {
+ globals.restore();
+ clock.restore();
+ });
+
+ describe("#lazyloading TopStories", () => {
+ beforeEach(() => {
+ instance.discoveryStreamEnabled = true;
+ });
+ it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is true", () => {
+ instance.discoveryStreamEnabled = false;
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: true }),
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+ instance.onAction({ type: at.INIT, data: {} });
+
+ assert.calledOnce(sectionsManagerStub.onceInitialized);
+ });
+ it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is false", () => {
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: false }),
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.calledOnce(sectionsManagerStub.onceInitialized);
+ });
+ it("Should initialize properties once while lazy loading if not initialized earlier", () => {
+ instance.discoveryStreamEnabled = false;
+ instance.propertiesInitialized = false;
+ sinon.stub(instance, "initializeProperties");
+ instance.lazyLoadTopStories();
+ assert.calledOnce(instance.initializeProperties);
+ });
+ it("should not re-initialize properties", () => {
+ // For discovery stream experience disabled TopStoriesFeed properties
+ // are initialized in constructor and should not be called again while lazy loading topstories
+ sinon.stub(instance, "initializeProperties");
+ instance.discoveryStreamEnabled = false;
+ instance.propertiesInitialized = true;
+ instance.lazyLoadTopStories();
+ assert.notCalled(instance.initializeProperties);
+ });
+ it("should have early exit onInit when discovery is true", async () => {
+ sinon.stub(instance, "doContentUpdate");
+ await instance.onInit();
+ assert.notCalled(instance.doContentUpdate);
+ assert.isUndefined(instance.storiesLoaded);
+ });
+ it("should complete onInit when discovery is false", async () => {
+ instance.discoveryStreamEnabled = false;
+ sinon.stub(instance, "doContentUpdate");
+ await instance.onInit();
+ assert.calledOnce(instance.doContentUpdate);
+ assert.isTrue(instance.storiesLoaded);
+ });
+ it("should handle limited actions when discoverystream is enabled", async () => {
+ sinon.spy(instance, "handleDisabled");
+ sinon.stub(instance, "getPocketState");
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: true }),
+ "discoverystream.enabled": true,
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+
+ instance.onAction({ type: at.INIT, data: {} });
+
+ assert.calledOnce(instance.handleDisabled);
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.getPocketState);
+ });
+ it("should handle NEW_TAB_REHYDRATED when discoverystream is disabled", async () => {
+ instance.discoveryStreamEnabled = false;
+ sinon.spy(instance, "handleDisabled");
+ sinon.stub(instance, "getPocketState");
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: false }),
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.notCalled(instance.handleDisabled);
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.getPocketState);
+ });
+ it("should handle UNINIT when discoverystream is enabled", async () => {
+ sinon.stub(instance, "uninit");
+ instance.onAction({ type: at.UNINIT });
+ assert.calledOnce(instance.uninit);
+ });
+ it("should fire init on PREF_CHANGED", () => {
+ sinon.stub(instance, "onInit");
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.config", value: {} },
+ });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should fire init on DISCOVERY_STREAM_PREF_ENABLED", () => {
+ sinon.stub(instance, "onInit");
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.enabled", value: true },
+ });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should not fire init on PREF_CHANGED if stories are loaded", () => {
+ sinon.stub(instance, "onInit");
+ sinon.spy(instance, "lazyLoadTopStories");
+ instance.storiesLoaded = true;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.config", value: {} },
+ });
+ assert.calledOnce(instance.lazyLoadTopStories);
+ assert.notCalled(instance.onInit);
+ });
+ it("should fire init on PREF_CHANGED when discoverystream is disabled", () => {
+ instance.discoveryStreamEnabled = false;
+ sinon.stub(instance, "onInit");
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.config", value: {} },
+ });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should not fire init on PREF_CHANGED when discoverystream is disabled and stories are loaded", () => {
+ instance.discoveryStreamEnabled = false;
+ sinon.stub(instance, "onInit");
+ sinon.spy(instance, "lazyLoadTopStories");
+ instance.storiesLoaded = true;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.config", value: {} },
+ });
+ assert.calledOnce(instance.lazyLoadTopStories);
+ assert.notCalled(instance.onInit);
+ });
+ it("should not init props if ds pref is true", () => {
+ sinon.stub(instance, "initializeProperties");
+ instance.propertiesInitialized = false;
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: false }),
+ "discoverystream.enabled": true,
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+ instance.lazyLoadTopStories({
+ dsPref: JSON.stringify({ enabled: true }),
+ });
+ assert.notCalled(instance.initializeProperties);
+ });
+ it("should fire init if user pref is true", () => {
+ sinon.stub(instance, "onInit");
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: false }),
+ "discoverystream.enabled": false,
+ "feeds.section.topstories": false,
+ },
+ },
+ });
+ instance.lazyLoadTopStories({ userPref: true });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should fire uninit if topstories update to false", () => {
+ sinon.stub(instance, "uninit");
+ instance.discoveryStreamEnabled = false;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ value: false,
+ name: "feeds.section.topstories",
+ },
+ });
+ assert.calledOnce(instance.uninit);
+ instance.discoveryStreamEnabled = true;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ value: false,
+ name: "feeds.section.topstories",
+ },
+ });
+ assert.calledTwice(instance.uninit);
+ });
+ it("should fire lazyLoadTopstories if topstories update to true", () => {
+ sinon.stub(instance, "lazyLoadTopStories");
+ instance.discoveryStreamEnabled = false;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ value: true,
+ name: "feeds.section.topstories",
+ },
+ });
+ assert.calledOnce(instance.lazyLoadTopStories);
+ instance.discoveryStreamEnabled = true;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ value: true,
+ name: "feeds.section.topstories",
+ },
+ });
+ assert.calledTwice(instance.lazyLoadTopStories);
+ });
+ });
+
+ describe("#init", () => {
+ it("should create a TopStoriesFeed", () => {
+ assert.instanceOf(instance, TopStoriesFeed);
+ });
+ it("should bind parseOptions to SectionsManager.onceInitialized", () => {
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.calledOnce(sectionsManagerStub.onceInitialized);
+ });
+ it("should initialize endpoints based on options", async () => {
+ await instance.onInit();
+ assert.equal(
+ "https://somedomain.org/stories?key=test-api-key",
+ instance.stories_endpoint
+ );
+ assert.equal(
+ "https://somedomain.org/referrer",
+ instance.stories_referrer
+ );
+ assert.equal(
+ "https://somedomain.org/topics?key=test-api-key",
+ instance.topics_endpoint
+ );
+ });
+ it("should enable its section", () => {
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.calledOnce(sectionsManagerStub.enableSection);
+ assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID);
+ });
+ it("init should fire onInit", () => {
+ instance.onInit = sinon.spy();
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should fetch stories on init", async () => {
+ instance.fetchStories = sinon.spy();
+ await instance.onInit();
+ assert.calledOnce(instance.fetchStories);
+ });
+ it("should fetch topics on init", async () => {
+ instance.fetchTopics = sinon.spy();
+ await instance.onInit();
+ assert.calledOnce(instance.fetchTopics);
+ });
+ it("should not fetch if endpoint not configured", () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ sectionsManagerStub.sections.set("topstories", { options: {} });
+ instance.init();
+ assert.notCalled(fetchStub);
+ });
+ it("should report error for invalid configuration", () => {
+ globals.sandbox.spy(global.console, "error");
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ api_key_pref: "invalid",
+ stories_endpoint: "https://invalid.com/?apiKey=$apiKey",
+ },
+ });
+ instance.init();
+
+ assert.calledWith(
+ console.error,
+ "Problem initializing top stories feed: An API key was specified but none configured: https://invalid.com/?apiKey=$apiKey"
+ );
+ });
+ it("should report error for missing api key", () => {
+ globals.sandbox.spy(global.console, "error");
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ stories_endpoint: "https://somedomain.org/stories?key=$apiKey",
+ topics_endpoint: "https://somedomain.org/topics?key=$apiKey",
+ },
+ });
+ instance.init();
+
+ assert.called(console.error);
+ });
+ it("should load data from cache on init", async () => {
+ instance.loadCachedData = sinon.spy();
+ await instance.onInit();
+ assert.calledOnce(instance.loadCachedData);
+ });
+ });
+ describe("#uninit", () => {
+ it("should disable its section", () => {
+ instance.onAction({ type: at.UNINIT });
+ assert.calledOnce(sectionsManagerStub.disableSection);
+ assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID);
+ });
+ it("should unload stories on uninit", async () => {
+ sinon.stub(instance.cache, "set").returns(Promise.resolve());
+ await instance.clearCache();
+ assert.calledWith(instance.cache.set.firstCall, "stories", {});
+ assert.calledWith(instance.cache.set.secondCall, "topics", {});
+ assert.calledWith(instance.cache.set.thirdCall, "spocs", {});
+ });
+ });
+ describe("#cache", () => {
+ it("should clear all cache items when calling clearCache", () => {
+ sinon.stub(instance.cache, "set").returns(Promise.resolve());
+ instance.storiesLoaded = true;
+ instance.uninit();
+ assert.equal(instance.storiesLoaded, false);
+ });
+ it("should set spocs cache on fetch", async () => {
+ const response = {
+ recommendations: [{ id: "1" }, { id: "2" }],
+ settings: {},
+ spocs: [{ id: "spoc1" }],
+ };
+
+ instance.show_spocs = true;
+ instance.stories_endpoint = "stories-endpoint";
+
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ sinon.spy(instance.cache, "set");
+
+ await instance.fetchStories();
+
+ assert.calledOnce(instance.cache.set);
+ const { args } = instance.cache.set.firstCall;
+ assert.equal(args[0], "stories");
+ assert.equal(args[1].spocs[0].id, "spoc1");
+ });
+ it("should get spocs on cache load", async () => {
+ instance.cache.get = () => ({
+ stories: {
+ recommendations: [{ id: "1" }, { id: "2" }],
+ spocs: [{ id: "spoc1" }],
+ },
+ });
+ instance.storiesLastUpdated = 0;
+ globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
+
+ await instance.loadCachedData();
+ assert.equal(instance.spocs[0].guid, "spoc1");
+ });
+ });
+ describe("#fetch", () => {
+ it("should fetch stories, send event and cache results", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ stories_endpoint: "stories-endpoint",
+ stories_referrer: "referrer",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ recommendations: [
+ {
+ id: "1",
+ title: "title",
+ excerpt: "description",
+ image_src: "image-url",
+ url: "rec-url",
+ published_timestamp: "123",
+ context: "trending",
+ icon: "icon",
+ },
+ ],
+ };
+ const stories = [
+ {
+ guid: "1",
+ type: "now",
+ title: "title",
+ context: "trending",
+ icon: "icon",
+ description: "description",
+ image: "image-url",
+ referrer: "referrer",
+ url: "rec-url",
+ hostname: "rec-url",
+ score: 1,
+ spoc_meta: {},
+ },
+ ];
+
+ instance.cache.set = sinon.spy();
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ assert.calledOnce(fetchStub);
+ assert.calledOnce(shortURLStub);
+ assert.calledWithExactly(fetchStub, instance.stories_endpoint, {
+ credentials: "omit",
+ });
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
+ rows: stories,
+ });
+ assert.calledOnce(instance.cache.set);
+ assert.calledWith(
+ instance.cache.set,
+ "stories",
+ Object.assign({}, response, { _timestamp: 0 })
+ );
+ });
+ it("should use domain as hostname, if present", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ stories_endpoint: "stories-endpoint",
+ stories_referrer: "referrer",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ recommendations: [
+ {
+ id: "1",
+ title: "title",
+ excerpt: "description",
+ image_src: "image-url",
+ url: "rec-url",
+ domain: "domain",
+ published_timestamp: "123",
+ context: "trending",
+ icon: "icon",
+ },
+ ],
+ };
+ const stories = [
+ {
+ guid: "1",
+ type: "now",
+ title: "title",
+ context: "trending",
+ icon: "icon",
+ description: "description",
+ image: "image-url",
+ referrer: "referrer",
+ url: "rec-url",
+ hostname: "domain",
+ score: 1,
+ spoc_meta: {},
+ },
+ ];
+
+ instance.cache.set = sinon.spy();
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ assert.calledOnce(fetchStub);
+ assert.notCalled(shortURLStub);
+ assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
+ rows: stories,
+ });
+ });
+ it("should call SectionsManager.updateSection", () => {
+ instance.dispatchUpdateEvent(123, {});
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ });
+ it("should report error for unexpected stories response", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_endpoint: "stories-endpoint" },
+ });
+ globals.set("fetch", fetchStub);
+ globals.sandbox.spy(global.console, "error");
+
+ fetchStub.resolves({ ok: false, status: 400 });
+ await instance.onInit();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.stories_endpoint, {
+ credentials: "omit",
+ });
+ assert.equal(instance.storiesLastUpdated, 0);
+ assert.called(console.error);
+ });
+ it("should exclude blocked (dismissed) URLs", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_endpoint: "stories-endpoint" },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: site => site.url === "blocked" },
+ });
+
+ const response = {
+ recommendations: [{ url: "blocked" }, { url: "not_blocked" }],
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ // Issue!
+ // Should actually be fixed when cache is fixed.
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows.length,
+ 1
+ );
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows[0].url,
+ "not_blocked"
+ );
+ });
+ it("should mark stories as new", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_endpoint: "stories-endpoint" },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ clock.restore();
+ const response = {
+ recommendations: [
+ { published_timestamp: Date.now() / 1000 },
+ { published_timestamp: "0" },
+ {
+ published_timestamp: (Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000,
+ },
+ ],
+ };
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+
+ await instance.onInit();
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows.length,
+ 3
+ );
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows[0].type,
+ "now"
+ );
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows[1].type,
+ "trending"
+ );
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows[2].type,
+ "trending"
+ );
+ });
+ it("should fetch topics, send event and cache results", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: { topics_endpoint: "topics-endpoint" },
+ });
+ globals.set("fetch", fetchStub);
+
+ const response = {
+ topics: [
+ { name: "topic1", url: "url-topic1" },
+ { name: "topic2", url: "url-topic2" },
+ ],
+ };
+ const topics = [
+ {
+ name: "topic1",
+ url: "url-topic1",
+ },
+ {
+ name: "topic2",
+ url: "url-topic2",
+ },
+ ];
+
+ instance.cache.set = sinon.spy();
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.topics_endpoint, {
+ credentials: "omit",
+ });
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWithMatch(sectionsManagerStub.updateSection, SECTION_ID, {
+ topics,
+ });
+ assert.calledOnce(instance.cache.set);
+ assert.calledWith(
+ instance.cache.set,
+ "topics",
+ Object.assign({}, response, { _timestamp: 0 })
+ );
+ });
+ it("should report error for unexpected topics response", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.sandbox.spy(global.console, "error");
+
+ instance.topics_endpoint = "topics-endpoint";
+ fetchStub.resolves({ ok: false, status: 400 });
+ await instance.fetchTopics();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.topics_endpoint, {
+ credentials: "omit",
+ });
+ assert.notCalled(instance.store.dispatch);
+ assert.called(console.error);
+ });
+ });
+ describe("#personalization", () => {
+ it("should sort stories", async () => {
+ const response = {
+ recommendations: [{ id: "1" }, { id: "2" }],
+ settings: {},
+ };
+
+ instance.compareScore = sinon.spy();
+ instance.stories_endpoint = "stories-endpoint";
+
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+
+ await instance.fetchStories();
+ assert.calledOnce(instance.compareScore);
+ });
+ it("should sort items based on relevance score", () => {
+ let items = [{ score: 0.1 }, { score: 0.2 }];
+ items = items.sort(instance.compareScore);
+ assert.deepEqual(items, [{ score: 0.2 }, { score: 0.1 }]);
+ });
+ it("should rotate items", () => {
+ let items = [
+ { guid: "g1" },
+ { guid: "g2" },
+ { guid: "g3" },
+ { guid: "g4" },
+ { guid: "g5" },
+ { guid: "g6" },
+ ];
+
+ // No impressions should leave items unchanged
+ let rotated = instance.rotate(items);
+ assert.deepEqual(items, rotated);
+
+ // Recent impression should leave items unchanged
+ instance._prefs.get = pref =>
+ pref === REC_IMPRESSION_TRACKING_PREF &&
+ JSON.stringify({ g1: 1, g2: 1, g3: 1 });
+ rotated = instance.rotate(items);
+ assert.deepEqual(items, rotated);
+
+ // Impression older than expiration time should rotate items
+ clock.tick(DEFAULT_RECS_EXPIRE_TIME + 1);
+ rotated = instance.rotate(items);
+ assert.deepEqual(
+ [
+ { guid: "g4" },
+ { guid: "g5" },
+ { guid: "g6" },
+ { guid: "g1" },
+ { guid: "g2" },
+ { guid: "g3" },
+ ],
+ rotated
+ );
+
+ instance._prefs.get = pref =>
+ pref === REC_IMPRESSION_TRACKING_PREF &&
+ JSON.stringify({
+ g1: 1,
+ g2: 1,
+ g3: 1,
+ g4: DEFAULT_RECS_EXPIRE_TIME + 1,
+ });
+ clock.tick(DEFAULT_RECS_EXPIRE_TIME);
+ rotated = instance.rotate(items);
+ assert.deepEqual(
+ [
+ { guid: "g5" },
+ { guid: "g6" },
+ { guid: "g1" },
+ { guid: "g2" },
+ { guid: "g3" },
+ { guid: "g4" },
+ ],
+ rotated
+ );
+ });
+ it("should record top story impressions", async () => {
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+
+ clock.tick(1);
+ let expectedPrefValue = JSON.stringify({ 1: 1, 2: 1, 3: 1 });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 1 }, { id: 2 }, { id: 3 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.firstCall,
+ REC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValue
+ );
+
+ // Only need to record first impression, so impression pref shouldn't change
+ instance._prefs.get = pref => expectedPrefValue;
+ clock.tick(1);
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 1 }, { id: 2 }, { id: 3 }],
+ },
+ });
+ assert.calledOnce(instance._prefs.set);
+
+ // New first impressions should be added
+ clock.tick(1);
+ let expectedPrefValueTwo = JSON.stringify({
+ 1: 1,
+ 2: 1,
+ 3: 1,
+ 4: 3,
+ 5: 3,
+ 6: 3,
+ });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 4 }, { id: 5 }, { id: 6 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.secondCall,
+ REC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValueTwo
+ );
+ });
+ it("should not record top story impressions for non-view impressions", async () => {
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+ });
+ it("should clean up top story impressions", async () => {
+ instance._prefs = {
+ get: pref => JSON.stringify({ 1: 1, 2: 1, 3: 1 }),
+ set: sinon.spy(),
+ };
+
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ instance.stories_endpoint = "stories-endpoint";
+ const response = { recommendations: [{ id: 3 }, { id: 4 }, { id: 5 }] };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.fetchStories();
+
+ // Should remove impressions for rec 1 and 2 as no longer in the feed
+ assert.calledWith(
+ instance._prefs.set.firstCall,
+ REC_IMPRESSION_TRACKING_PREF,
+ JSON.stringify({ 3: 1 })
+ );
+ });
+ it("should not change provider with badly formed JSON", async () => {
+ sinon.stub(instance, "uninit");
+ sinon.stub(instance, "init");
+ sinon.stub(instance, "clearCache").returns(Promise.resolve());
+ await instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "feeds.section.topstories.options",
+ value: "{version: 2}",
+ },
+ });
+ assert.notCalled(instance.uninit);
+ assert.notCalled(instance.init);
+ assert.notCalled(instance.clearCache);
+ });
+ });
+ describe("#spocs", async () => {
+ it("should not display expired or untimestamped spocs", async () => {
+ clock.tick(441792000000); // 01/01/1984
+
+ instance.spocsPerNewTabs = 1;
+ instance.show_spocs = true;
+ instance.isBelowFrequencyCap = () => true;
+
+ // NOTE: `expiration_timestamp` is seconds since UNIX epoch
+ instance.spocs = [
+ // No timestamp stays visible
+ {
+ id: "spoc1",
+ },
+ // Expired spoc gets filtered out
+ {
+ id: "spoc2",
+ expiration_timestamp: 1,
+ },
+ // Far future expiration spoc stays visible
+ {
+ id: "spoc3",
+ expiration_timestamp: 32503708800, // 01/01/3000
+ },
+ ];
+
+ sinon.spy(instance, "filterSpocs");
+
+ instance.filterSpocs();
+
+ assert.equal(instance.filterSpocs.firstCall.returnValue.length, 2);
+ assert.equal(instance.filterSpocs.firstCall.returnValue[0].id, "spoc1");
+ assert.equal(instance.filterSpocs.firstCall.returnValue[1].id, "spoc3");
+ });
+ it("should insert spoc with provided probability", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
+ // Include spocs with a expiration in the very distant future
+ spocs: [
+ { id: "spoc1", expiration_timestamp: 9999999999999 },
+ { id: "spoc2", expiration_timestamp: 9999999999999 },
+ ],
+ };
+
+ instance.show_spocs = true;
+ instance.stories_endpoint = "stories-endpoint";
+ instance.storiesLoaded = true;
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.fetchStories();
+
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: true } },
+ });
+
+ globals.set("Math", {
+ random: () => 0.4,
+ min: Math.min,
+ });
+ instance.dispatchSpocDone = () => {};
+ instance.getPocketState = () => {};
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.store.dispatch);
+ let [action] = instance.store.dispatch.firstCall.args;
+
+ assert.equal(at.SECTION_UPDATE, action.type);
+ assert.equal(true, action.meta.skipMain);
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
+ assert.equal(action.data.rows[2].pinned, true);
+
+ // Second new tab shouldn't trigger a section update event (spocsPerNewTab === 0.5)
+ globals.set("Math", {
+ random: () => 0.6,
+ min: Math.min,
+ });
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.store.dispatch);
+
+ globals.set("Math", {
+ random: () => 0.3,
+ min: Math.min,
+ });
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledTwice(instance.store.dispatch);
+ [action] = instance.store.dispatch.secondCall.args;
+ assert.equal(at.SECTION_UPDATE, action.type);
+ assert.equal(true, action.meta.skipMain);
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
+ assert.equal(action.data.rows[2].pinned, true);
+ });
+ it("should delay inserting spoc if stories haven't been fetched", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ globals.set("Math", {
+ random: () => 0.4,
+ min: Math.min,
+ floor: Math.floor,
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }],
+ // Include one spoc with a expiration in the very distant future
+ spocs: [
+ { id: "spoc1", expiration_timestamp: 9999999999999 },
+ { id: "spoc2" },
+ ],
+ };
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.store.dispatch);
+ assert.equal(instance.contentUpdateQueue.length, 1);
+
+ instance.spocsPerNewTabs = 0.5;
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: true } },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+
+ await instance.onInit();
+ assert.equal(instance.contentUpdateQueue.length, 0);
+ assert.calledOnce(instance.store.dispatch);
+ let [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.type, at.SECTION_UPDATE);
+ });
+ it("should not insert spoc if preffed off", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: false,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [{ id: "spoc1" }, { id: "spoc2" }],
+ };
+ sinon.spy(instance, "maybeAddSpoc");
+ sinon.spy(instance, "shouldShowSpocs");
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.maybeAddSpoc);
+ assert.calledOnce(instance.shouldShowSpocs);
+ assert.notCalled(instance.store.dispatch);
+ });
+ it("should call dispatchSpocDone when calling maybeAddSpoc", async () => {
+ instance.dispatchSpocDone = sinon.spy();
+ instance.storiesLoaded = true;
+ await instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.dispatchSpocDone);
+ assert.calledWith(instance.dispatchSpocDone, {});
+ });
+ it("should fire POCKET_WAITING_FOR_SPOC action with false", () => {
+ instance.dispatchSpocDone({});
+ assert.calledOnce(instance.store.dispatch);
+ const [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.type, "POCKET_WAITING_FOR_SPOC");
+ assert.equal(action.data, false);
+ });
+ it("should not insert spoc if user opted out", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [{ id: "spoc1" }, { id: "spoc2" }],
+ };
+
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: false } },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.store.dispatch);
+ });
+ it("should not fail if there is no spoc", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ globals.set("Math", {
+ random: () => 0.4,
+ min: Math.min,
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }],
+ };
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.store.dispatch);
+ });
+ it("should record spoc/campaign impressions for frequency capping", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ globals.set("Math", {
+ random: () => 0.4,
+ min: Math.min,
+ floor: Math.floor,
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [
+ { id: 1, campaign_id: 5 },
+ { id: 4, campaign_id: 6 },
+ ],
+ };
+
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+ instance.show_spocs = true;
+ instance.stories_endpoint = "stories-endpoint";
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.fetchStories();
+
+ let expectedPrefValue = JSON.stringify({ 5: [0] });
+ let expectedPrefValueCallTwo = JSON.stringify({ 2: 0, 3: 0 });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.firstCall,
+ SPOC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValue
+ );
+ assert.calledWith(
+ instance._prefs.set.secondCall,
+ REC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValueCallTwo
+ );
+
+ clock.tick(1);
+ instance._prefs.get = pref => expectedPrefValue;
+ let expectedPrefValueCallThree = JSON.stringify({ 5: [0, 1] });
+ let expectedPrefValueCallFour = JSON.stringify({ 2: 1, 3: 1, 5: [0] });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.thirdCall,
+ SPOC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValueCallThree
+ );
+ assert.calledWith(
+ instance._prefs.set.getCall(3),
+ REC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValueCallFour
+ );
+
+ clock.tick(1);
+ instance._prefs.get = pref => expectedPrefValueCallThree;
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 4 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.getCall(4),
+ SPOC_IMPRESSION_TRACKING_PREF,
+ JSON.stringify({ 5: [0, 1], 6: [2] })
+ );
+ assert.calledWith(
+ instance._prefs.set.getCall(5),
+ REC_IMPRESSION_TRACKING_PREF,
+ JSON.stringify({ 2: 2, 3: 2, 5: [0, 1] })
+ );
+ });
+ it("should not record spoc/campaign impressions for non-view impressions", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [
+ { id: 1, campaign_id: 5 },
+ { id: 4, campaign_id: 6 },
+ ],
+ };
+
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+ });
+ it("should clean up spoc/campaign impressions", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+ instance.show_spocs = true;
+ instance.stories_endpoint = "stories-endpoint";
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [
+ { id: 1, campaign_id: 5 },
+ { id: 4, campaign_id: 6 },
+ ],
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.fetchStories();
+
+ // simulate impressions for campaign 5 and 6
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
+ },
+ });
+ instance._prefs.get = pref =>
+ pref === SPOC_IMPRESSION_TRACKING_PREF && JSON.stringify({ 5: [0] });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 4 }],
+ },
+ });
+
+ let expectedPrefValue = JSON.stringify({ 5: [0], 6: [0] });
+ assert.calledWith(
+ instance._prefs.set.thirdCall,
+ SPOC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValue
+ );
+ instance._prefs.get = pref =>
+ pref === SPOC_IMPRESSION_TRACKING_PREF && expectedPrefValue;
+
+ // remove campaign 5 from response
+ const updatedResponse = {
+ settings: { spocsPerNewTabs: 1 },
+ spocs: [{ id: 4, campaign_id: 6 }],
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(updatedResponse),
+ });
+ await instance.fetchStories();
+
+ // should remove campaign 5 from pref as no longer active
+ assert.calledWith(
+ instance._prefs.set.getCall(4),
+ SPOC_IMPRESSION_TRACKING_PREF,
+ JSON.stringify({ 6: [0] })
+ );
+ });
+ it("should maintain frequency caps when inserting spocs", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 1 },
+ recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
+ spocs: [
+ // Set spoc `expiration_timestamp`s in the very distant future to ensure they show up
+ {
+ id: "spoc1",
+ campaign_id: 1,
+ caps: { lifetime: 3, campaign: { count: 2, period: 3600 } },
+ expiration_timestamp: 999999999999,
+ },
+ {
+ id: "spoc2",
+ campaign_id: 2,
+ caps: { lifetime: 1 },
+ expiration_timestamp: 999999999999,
+ },
+ ],
+ };
+
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: true } },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+ instance.spocsPerNewTabs = 1;
+
+ clock.tick();
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ let [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ instance._prefs.get = pref => JSON.stringify({ 1: [1] });
+
+ clock.tick();
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ [action] = instance.store.dispatch.secondCall.args;
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ instance._prefs.get = pref => JSON.stringify({ 1: [1, 2] });
+
+ // campaign 1 period frequency cap now reached (spoc 2 should be shown)
+ clock.tick();
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ [action] = instance.store.dispatch.thirdCall.args;
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc2");
+ instance._prefs.get = pref => JSON.stringify({ 1: [1, 2], 2: [3] });
+
+ // new campaign 1 period starting (spoc 1 sohuld be shown again)
+ clock.tick(2 * 60 * 60 * 1000);
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ [action] = instance.store.dispatch.lastCall.args;
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ instance._prefs.get = pref =>
+ JSON.stringify({ 1: [1, 2, 7200003], 2: [3] });
+
+ // campaign 1 lifetime cap now reached (no spoc should be sent)
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.callCount(instance.store.dispatch, 4);
+ });
+ it("should maintain client-side MAX_LIFETIME_CAP", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+
+ const response = {
+ settings: { spocsPerNewTabs: 1 },
+ recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
+ spocs: [{ id: "spoc1", campaign_id: 1, caps: { lifetime: 501 } }],
+ };
+
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: true } },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance._prefs.get = pref =>
+ JSON.stringify({ 1: [...Array(500).keys()] });
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.store.dispatch);
+ });
+ });
+ describe("#update", () => {
+ it("should fetch stories after update interval", async () => {
+ await instance.onInit();
+ sinon.spy(instance, "fetchStories");
+ await instance.onAction({ type: at.SYSTEM_TICK });
+ assert.notCalled(instance.fetchStories);
+
+ clock.tick(STORIES_UPDATE_TIME);
+ await instance.onAction({ type: at.SYSTEM_TICK });
+ assert.calledOnce(instance.fetchStories);
+ });
+ it("should fetch topics after update interval", async () => {
+ await instance.onInit();
+ sinon.spy(instance, "fetchTopics");
+ await instance.onAction({ type: at.SYSTEM_TICK });
+ assert.notCalled(instance.fetchTopics);
+
+ clock.tick(TOPICS_UPDATE_TIME);
+ await instance.onAction({ type: at.SYSTEM_TICK });
+ assert.calledOnce(instance.fetchTopics);
+ });
+ it("should return updated stories and topics on system tick", async () => {
+ await instance.onInit();
+ sinon.spy(instance, "dispatchUpdateEvent");
+ const stories = [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }];
+ const topics = [
+ { name: "topic1", url: "url-topic1" },
+ { name: "topic2", url: "url-topic2" },
+ ];
+ clock.tick(TOPICS_UPDATE_TIME);
+ globals.sandbox.stub(instance, "fetchStories").resolves(stories);
+ globals.sandbox.stub(instance, "fetchTopics").resolves(topics);
+
+ await instance.onAction({ type: at.SYSTEM_TICK });
+
+ assert.calledOnce(instance.dispatchUpdateEvent);
+ assert.calledWith(instance.dispatchUpdateEvent, false, {
+ rows: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
+ topics: [
+ { name: "topic1", url: "url-topic1" },
+ { name: "topic2", url: "url-topic2" },
+ ],
+ read_more_endpoint: undefined,
+ });
+ });
+ it("should not call init and uninit if data doesn't match on options change ", () => {
+ sinon.spy(instance, "init");
+ sinon.spy(instance, "uninit");
+ instance.onAction({ type: at.SECTION_OPTIONS_CHANGED, data: "foo" });
+ assert.notCalled(sectionsManagerStub.disableSection);
+ assert.notCalled(sectionsManagerStub.enableSection);
+ assert.notCalled(instance.init);
+ assert.notCalled(instance.uninit);
+ });
+ it("should call init and uninit on options change", async () => {
+ sinon.stub(instance, "clearCache").returns(Promise.resolve());
+ sinon.spy(instance, "init");
+ sinon.spy(instance, "uninit");
+ await instance.onAction({
+ type: at.SECTION_OPTIONS_CHANGED,
+ data: "topstories",
+ });
+ assert.calledOnce(sectionsManagerStub.disableSection);
+ assert.calledOnce(sectionsManagerStub.enableSection);
+ assert.calledOnce(instance.clearCache);
+ assert.calledOnce(instance.init);
+ assert.calledOnce(instance.uninit);
+ });
+ it("should set LastUpdated to 0 on init", async () => {
+ instance.storiesLastUpdated = 1;
+ instance.topicsLastUpdated = 1;
+
+ await instance.onInit();
+ assert.equal(instance.storiesLastUpdated, 0);
+ assert.equal(instance.topicsLastUpdated, 0);
+ });
+ it("should filter spocs when link is blocked", async () => {
+ instance.spocs = [{ url: "not_blocked" }, { url: "blocked" }];
+ await instance.onAction({
+ type: at.PLACES_LINK_BLOCKED,
+ data: { url: "blocked" },
+ });
+
+ assert.deepEqual(instance.spocs, [{ url: "not_blocked" }]);
+ });
+ });
+ describe("#loadCachedData", () => {
+ it("should update section with cached stories and topics if available", async () => {
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_referrer: "referrer" },
+ });
+ const stories = {
+ _timestamp: 123,
+ recommendations: [
+ {
+ id: "1",
+ title: "title",
+ excerpt: "description",
+ image_src: "image-url",
+ url: "rec-url",
+ published_timestamp: "123",
+ context: "trending",
+ icon: "icon",
+ item_score: 0.98,
+ },
+ ],
+ };
+ const transformedStories = [
+ {
+ guid: "1",
+ type: "now",
+ title: "title",
+ context: "trending",
+ icon: "icon",
+ description: "description",
+ image: "image-url",
+ referrer: "referrer",
+ url: "rec-url",
+ hostname: "rec-url",
+ score: 0.98,
+ spoc_meta: {},
+ },
+ ];
+ const topics = {
+ _timestamp: 123,
+ topics: [
+ { name: "topic1", url: "url-topic1" },
+ { name: "topic2", url: "url-topic2" },
+ ],
+ };
+ instance.cache.get = () => ({ stories, topics });
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ await instance.onInit();
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
+ rows: transformedStories,
+ topics: topics.topics,
+ read_more_endpoint: undefined,
+ });
+ });
+ it("should NOT update section if there is no cached data", async () => {
+ instance.cache.get = () => ({});
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ await instance.loadCachedData();
+ assert.notCalled(sectionsManagerStub.updateSection);
+ });
+ it("should use store rows if no stories sent to doContentUpdate", async () => {
+ instance.store = {
+ getState() {
+ return {
+ Sections: [{ id: "topstories", rows: [1, 2, 3] }],
+ };
+ },
+ };
+ sinon.spy(instance, "dispatchUpdateEvent");
+
+ instance.doContentUpdate({}, false);
+
+ assert.calledOnce(instance.dispatchUpdateEvent);
+ assert.calledWith(instance.dispatchUpdateEvent, false, {
+ rows: [1, 2, 3],
+ });
+ });
+ it("should broadcast in doContentUpdate when updating from cache", async () => {
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_referrer: "referrer" },
+ });
+ globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
+ const stories = { recommendations: [{}] };
+ const topics = { topics: [{}] };
+ sinon.spy(instance, "doContentUpdate");
+ instance.cache.get = () => ({ stories, topics });
+ await instance.onInit();
+ assert.calledOnce(instance.doContentUpdate);
+ assert.calledWith(
+ instance.doContentUpdate,
+ {
+ stories: [
+ {
+ context: undefined,
+ description: undefined,
+ guid: undefined,
+ hostname: undefined,
+ icon: undefined,
+ image: undefined,
+ referrer: "referrer",
+ score: 1,
+ spoc_meta: {},
+ title: undefined,
+ type: "trending",
+ url: undefined,
+ },
+ ],
+ topics: [{}],
+ },
+ true
+ );
+ });
+ });
+ describe("#pocket", () => {
+ it("should call getPocketState when hitting NEW_TAB_REHYDRATED", () => {
+ instance.getPocketState = sinon.spy();
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.getPocketState);
+ assert.calledWith(instance.getPocketState, {});
+ });
+ it("should call dispatch in getPocketState", () => {
+ const isUserLoggedIn = sinon.spy();
+ globals.set("pktApi", { isUserLoggedIn });
+ instance.getPocketState({});
+ assert.calledOnce(instance.store.dispatch);
+ const [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.type, "POCKET_LOGGED_IN");
+ assert.calledOnce(isUserLoggedIn);
+ });
+ it("should call dispatchPocketCta when hitting onInit", async () => {
+ instance.dispatchPocketCta = sinon.spy();
+ await instance.onInit();
+ assert.calledOnce(instance.dispatchPocketCta);
+ assert.calledWith(
+ instance.dispatchPocketCta,
+ JSON.stringify({
+ cta_button: "",
+ cta_text: "",
+ cta_url: "",
+ use_cta: false,
+ }),
+ false
+ );
+ });
+ it("should call dispatch in dispatchPocketCta", () => {
+ instance.dispatchPocketCta(JSON.stringify({ use_cta: true }), false);
+ assert.calledOnce(instance.store.dispatch);
+ const [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.type, "POCKET_CTA");
+ assert.equal(action.data.use_cta, true);
+ });
+ it("should call dispatchPocketCta with a pocketCta pref change", () => {
+ instance.dispatchPocketCta = sinon.spy();
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "pocketCta",
+ value: JSON.stringify({
+ cta_button: "",
+ cta_text: "",
+ cta_url: "",
+ use_cta: false,
+ }),
+ },
+ });
+ assert.calledOnce(instance.dispatchPocketCta);
+ assert.calledWith(
+ instance.dispatchPocketCta,
+ JSON.stringify({
+ cta_button: "",
+ cta_text: "",
+ cta_url: "",
+ use_cta: false,
+ }),
+ true
+ );
+ });
+ });
+ it("should call uninit and init on disabling of showSponsored pref", async () => {
+ sinon.stub(instance, "clearCache").returns(Promise.resolve());
+ sinon.stub(instance, "uninit");
+ sinon.stub(instance, "init");
+ await instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored", value: false },
+ });
+ assert.calledOnce(instance.clearCache);
+ assert.calledOnce(instance.uninit);
+ assert.calledOnce(instance.init);
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/UTEventReporting.test.js b/browser/components/newtab/test/unit/lib/UTEventReporting.test.js
new file mode 100644
index 0000000000..6255568438
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/UTEventReporting.test.js
@@ -0,0 +1,115 @@
+import { UTSessionPing, UTUserEventPing } from "test/schemas/pings";
+import { GlobalOverrider } from "test/unit/utils";
+import { UTEventReporting } from "lib/UTEventReporting.sys.mjs";
+
+const FAKE_EVENT_PING_PC = {
+ event: "CLICK",
+ source: "TOP_SITES",
+ addon_version: "123",
+ user_prefs: 63,
+ session_id: "abc",
+ page: "about:newtab",
+ action_position: 5,
+ locale: "en-US",
+};
+const FAKE_SESSION_PING_PC = {
+ session_duration: 1234,
+ addon_version: "123",
+ user_prefs: 63,
+ session_id: "abc",
+ page: "about:newtab",
+ locale: "en-US",
+};
+const FAKE_EVENT_PING_UT = [
+ "activity_stream",
+ "event",
+ "CLICK",
+ "TOP_SITES",
+ {
+ addon_version: "123",
+ user_prefs: "63",
+ session_id: "abc",
+ page: "about:newtab",
+ action_position: "5",
+ },
+];
+const FAKE_SESSION_PING_UT = [
+ "activity_stream",
+ "end",
+ "session",
+ "1234",
+ {
+ addon_version: "123",
+ user_prefs: "63",
+ session_id: "abc",
+ page: "about:newtab",
+ },
+];
+
+describe("UTEventReporting", () => {
+ let globals;
+ let sandbox;
+ let utEvents;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ sandbox.stub(global.Services.telemetry, "setEventRecordingEnabled");
+ sandbox.stub(global.Services.telemetry, "recordEvent");
+
+ utEvents = new UTEventReporting();
+ });
+
+ afterEach(() => {
+ globals.restore();
+ });
+
+ describe("#sendUserEvent()", () => {
+ it("should queue up the correct data to send to Events Telemetry", async () => {
+ utEvents.sendUserEvent(FAKE_EVENT_PING_PC);
+ assert.calledWithExactly(
+ global.Services.telemetry.recordEvent,
+ ...FAKE_EVENT_PING_UT
+ );
+
+ let ping = global.Services.telemetry.recordEvent.firstCall.args;
+ assert.validate(ping, UTUserEventPing);
+ });
+ });
+
+ describe("#sendSessionEndEvent()", () => {
+ it("should queue up the correct data to send to Events Telemetry", async () => {
+ utEvents.sendSessionEndEvent(FAKE_SESSION_PING_PC);
+ assert.calledWithExactly(
+ global.Services.telemetry.recordEvent,
+ ...FAKE_SESSION_PING_UT
+ );
+
+ let ping = global.Services.telemetry.recordEvent.firstCall.args;
+ assert.validate(ping, UTSessionPing);
+ });
+ });
+
+ describe("#uninit()", () => {
+ it("should call setEventRecordingEnabled with a false value", () => {
+ assert.equal(
+ global.Services.telemetry.setEventRecordingEnabled.firstCall.args[0],
+ "activity_stream"
+ );
+ assert.equal(
+ global.Services.telemetry.setEventRecordingEnabled.firstCall.args[1],
+ true
+ );
+
+ utEvents.uninit();
+ assert.equal(
+ global.Services.telemetry.setEventRecordingEnabled.secondCall.args[0],
+ "activity_stream"
+ );
+ assert.equal(
+ global.Services.telemetry.setEventRecordingEnabled.secondCall.args[1],
+ false
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/unit-entry.js b/browser/components/newtab/test/unit/unit-entry.js
new file mode 100644
index 0000000000..803390a386
--- /dev/null
+++ b/browser/components/newtab/test/unit/unit-entry.js
@@ -0,0 +1,684 @@
+import {
+ EventEmitter,
+ FakePrefs,
+ FakensIPrefService,
+ GlobalOverrider,
+ FakeConsoleAPI,
+ FakeLogger,
+} from "test/unit/utils";
+import Adapter from "enzyme-adapter-react-16";
+import { chaiAssertions } from "test/schemas/pings";
+import chaiJsonSchema from "chai-json-schema";
+import enzyme from "enzyme";
+import FxMSCommonSchema from "../../content-src/asrouter/schemas/FxMSCommon.schema.json";
+
+enzyme.configure({ adapter: new Adapter() });
+
+// Cause React warnings to make tests that trigger them fail
+const origConsoleError = console.error;
+console.error = function (msg, ...args) {
+ origConsoleError.apply(console, [msg, ...args]);
+
+ if (
+ /(Invalid prop|Failed prop type|Check the render method|React Intl)/.test(
+ msg
+ )
+ ) {
+ throw new Error(msg);
+ }
+};
+
+const req = require.context(".", true, /\.test\.jsx?$/);
+const files = req.keys();
+
+// This exposes sinon assertions to chai.assert
+sinon.assert.expose(assert, { prefix: "" });
+
+chai.use(chaiAssertions);
+chai.use(chaiJsonSchema);
+chai.tv4.addSchema("file:///FxMSCommon.schema.json", FxMSCommonSchema);
+
+const overrider = new GlobalOverrider();
+
+const RemoteSettings = name => ({
+ get: () => {
+ if (name === "attachment") {
+ return Promise.resolve([{ attachment: {} }]);
+ }
+ return Promise.resolve([]);
+ },
+ on: () => {},
+ off: () => {},
+});
+RemoteSettings.pollChanges = () => {};
+
+class JSWindowActorParent {
+ sendAsyncMessage(name, data) {
+ return { name, data };
+ }
+}
+
+class JSWindowActorChild {
+ sendAsyncMessage(name, data) {
+ return { name, data };
+ }
+
+ sendQuery(name, data) {
+ return Promise.resolve({ name, data });
+ }
+
+ get contentWindow() {
+ return {
+ Promise,
+ };
+ }
+}
+
+// Detect plain object passed to lazy getter APIs, and set its prototype to
+// global object, and return the global object for further modification.
+// Returns the object if it's not plain object.
+//
+// This is a workaround to make the existing testharness and testcase keep
+// working even after lazy getters are moved to plain `lazy` object.
+const cachedPlainObject = new Set();
+function updateGlobalOrObject(object) {
+ // Given this function modifies the prototype, and the following
+ // condition doesn't meet on the second call, cache the result.
+ if (cachedPlainObject.has(object)) {
+ return global;
+ }
+
+ if (Object.getPrototypeOf(object).constructor.name !== "Object") {
+ return object;
+ }
+
+ cachedPlainObject.add(object);
+ Object.setPrototypeOf(object, global);
+ return global;
+}
+
+const TEST_GLOBAL = {
+ JSWindowActorParent,
+ JSWindowActorChild,
+ AboutReaderParent: {
+ addMessageListener: (messageName, listener) => {},
+ removeMessageListener: (messageName, listener) => {},
+ },
+ AboutWelcomeTelemetry: class {
+ submitGleanPingForPing() {}
+ },
+ AddonManager: {
+ getActiveAddons() {
+ return Promise.resolve({ addons: [], fullData: false });
+ },
+ },
+ AppConstants: {
+ MOZILLA_OFFICIAL: true,
+ MOZ_APP_VERSION: "69.0a1",
+ isChinaRepack() {
+ return false;
+ },
+ isPlatformAndVersionAtMost() {
+ return false;
+ },
+ platform: "win",
+ },
+ ASRouterPreferences: {
+ console: new FakeConsoleAPI({
+ maxLogLevel: "off", // set this to "debug" or "all" to get more ASRouter logging in tests
+ prefix: "ASRouter",
+ }),
+ },
+ AWScreenUtils: {
+ evaluateTargetingAndRemoveScreens() {
+ return true;
+ },
+ async removeScreens() {
+ return true;
+ },
+ evaluateScreenTargeting() {
+ return true;
+ },
+ },
+ BrowserUtils: {
+ sendToDeviceEmailsSupported() {
+ return true;
+ },
+ },
+ UpdateUtils: { getUpdateChannel() {} },
+ BasePromiseWorker: class {
+ constructor() {
+ this.ExceptionHandlers = [];
+ }
+ post() {}
+ },
+ browserSearchRegion: "US",
+ BrowserWindowTracker: { getTopWindow() {} },
+ ChromeUtils: {
+ defineModuleGetter: updateGlobalOrObject,
+ defineESModuleGetters: updateGlobalOrObject,
+ generateQI() {
+ return {};
+ },
+ import() {
+ return global;
+ },
+ importESModule() {
+ return global;
+ },
+ },
+ ClientEnvironment: {
+ get userId() {
+ return "foo123";
+ },
+ },
+ Components: {
+ Constructor(classId) {
+ switch (classId) {
+ case "@mozilla.org/referrer-info;1":
+ return function (referrerPolicy, sendReferrer, originalReferrer) {
+ this.referrerPolicy = referrerPolicy;
+ this.sendReferrer = sendReferrer;
+ this.originalReferrer = originalReferrer;
+ };
+ }
+ return function () {};
+ },
+ isSuccessCode: () => true,
+ },
+ ConsoleAPI: FakeConsoleAPI,
+ // NB: These are functions/constructors
+ // eslint-disable-next-line object-shorthand
+ ContentSearchUIController: function () {},
+ // eslint-disable-next-line object-shorthand
+ ContentSearchHandoffUIController: function () {},
+ Cc: {
+ "@mozilla.org/browser/nav-bookmarks-service;1": {
+ addObserver() {},
+ getService() {
+ return this;
+ },
+ removeObserver() {},
+ SOURCES: {},
+ TYPE_BOOKMARK: {},
+ },
+ "@mozilla.org/browser/nav-history-service;1": {
+ addObserver() {},
+ executeQuery() {},
+ getNewQuery() {},
+ getNewQueryOptions() {},
+ getService() {
+ return this;
+ },
+ insert() {},
+ markPageAsTyped() {},
+ removeObserver() {},
+ },
+ "@mozilla.org/io/string-input-stream;1": {
+ createInstance() {
+ return {};
+ },
+ },
+ "@mozilla.org/security/hash;1": {
+ createInstance() {
+ return {
+ init() {},
+ updateFromStream() {},
+ finish() {
+ return "0";
+ },
+ };
+ },
+ },
+ "@mozilla.org/updates/update-checker;1": { createInstance() {} },
+ "@mozilla.org/widget/useridleservice;1": {
+ getService() {
+ return {
+ idleTime: 0,
+ addIdleObserver() {},
+ removeIdleObserver() {},
+ };
+ },
+ },
+ "@mozilla.org/streamConverters;1": {
+ getService() {
+ return this;
+ },
+ },
+ "@mozilla.org/network/stream-loader;1": {
+ createInstance() {
+ return {};
+ },
+ },
+ },
+ Ci: {
+ nsICryptoHash: {},
+ nsIReferrerInfo: { UNSAFE_URL: 5 },
+ nsITimer: { TYPE_ONE_SHOT: 1 },
+ nsIWebProgressListener: { LOCATION_CHANGE_SAME_DOCUMENT: 1 },
+ nsIDOMWindow: Object,
+ nsITrackingDBService: {
+ TRACKERS_ID: 1,
+ TRACKING_COOKIES_ID: 2,
+ CRYPTOMINERS_ID: 3,
+ FINGERPRINTERS_ID: 4,
+ SOCIAL_ID: 5,
+ },
+ nsICookieBannerService: {
+ MODE_DISABLED: 0,
+ MODE_REJECT: 1,
+ MODE_REJECT_OR_ACCEPT: 2,
+ MODE_UNSET: 3,
+ },
+ },
+ Cu: {
+ importGlobalProperties() {},
+ now: () => window.performance.now(),
+ cloneInto: o => JSON.parse(JSON.stringify(o)),
+ },
+ console: {
+ ...console,
+ error() {},
+ },
+ dump() {},
+ EveryWindow: {
+ registerCallback: (id, init, uninit) => {},
+ unregisterCallback: id => {},
+ },
+ setTimeout: window.setTimeout.bind(window),
+ clearTimeout: window.clearTimeout.bind(window),
+ fetch() {},
+ // eslint-disable-next-line object-shorthand
+ Image: function () {}, // NB: This is a function/constructor
+ IOUtils: {
+ writeJSON() {
+ return Promise.resolve(0);
+ },
+ readJSON() {
+ return Promise.resolve({});
+ },
+ read() {
+ return Promise.resolve(new Uint8Array());
+ },
+ makeDirectory() {
+ return Promise.resolve(0);
+ },
+ write() {
+ return Promise.resolve(0);
+ },
+ exists() {
+ return Promise.resolve(0);
+ },
+ remove() {
+ return Promise.resolve(0);
+ },
+ stat() {
+ return Promise.resolve(0);
+ },
+ },
+ NewTabUtils: {
+ activityStreamProvider: {
+ getTopFrecentSites: () => [],
+ executePlacesQuery: async (sql, options) => ({ sql, options }),
+ },
+ },
+ OS: {
+ File: {
+ writeAtomic() {},
+ makeDir() {},
+ stat() {},
+ Error: {},
+ read() {},
+ exists() {},
+ remove() {},
+ removeEmptyDir() {},
+ },
+ Path: {
+ join() {
+ return "/";
+ },
+ },
+ Constants: {
+ Path: {
+ localProfileDir: "/",
+ },
+ },
+ },
+ PathUtils: {
+ join(...parts) {
+ return parts[parts.length - 1];
+ },
+ joinRelative(...parts) {
+ return parts[parts.length - 1];
+ },
+ getProfileDir() {
+ return Promise.resolve("/");
+ },
+ getLocalProfileDir() {
+ return Promise.resolve("/");
+ },
+ },
+ PlacesUtils: {
+ get bookmarks() {
+ return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-bookmarks-service;1"];
+ },
+ get history() {
+ return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-history-service;1"];
+ },
+ observers: {
+ addListener() {},
+ removeListener() {},
+ },
+ },
+ PluralForm: { get() {} },
+ Preferences: FakePrefs,
+ PrivateBrowsingUtils: {
+ isBrowserPrivate: () => false,
+ isWindowPrivate: () => false,
+ permanentPrivateBrowsing: false,
+ },
+ DownloadsViewUI: {
+ getDisplayName: () => "filename.ext",
+ getSizeWithUnits: () => "1.5 MB",
+ },
+ FileUtils: {
+ // eslint-disable-next-line object-shorthand
+ File: function () {}, // NB: This is a function/constructor
+ },
+ Region: {
+ home: "US",
+ REGION_TOPIC: "browser-region-updated",
+ },
+ Services: {
+ dirsvc: {
+ get: () => ({ parent: { parent: { path: "appPath" } } }),
+ },
+ env: {
+ set: () => undefined,
+ },
+ locale: {
+ get appLocaleAsBCP47() {
+ return "en-US";
+ },
+ negotiateLanguages() {},
+ },
+ urlFormatter: { formatURL: str => str, formatURLPref: str => str },
+ mm: {
+ addMessageListener: (msg, cb) => this.receiveMessage(),
+ removeMessageListener() {},
+ },
+ obs: {
+ addObserver() {},
+ removeObserver() {},
+ notifyObservers() {},
+ },
+ telemetry: {
+ setEventRecordingEnabled: () => {},
+ recordEvent: eventDetails => {},
+ scalarSet: () => {},
+ keyedScalarAdd: () => {},
+ },
+ uuid: {
+ generateUUID() {
+ return "{foo-123-foo}";
+ },
+ },
+ console: { logStringMessage: () => {} },
+ prefs: new FakensIPrefService(),
+ tm: {
+ dispatchToMainThread: cb => cb(),
+ idleDispatchToMainThread: cb => cb(),
+ },
+ eTLD: {
+ getBaseDomain({ spec }) {
+ return spec.match(/\/([^/]+)/)[1];
+ },
+ getBaseDomainFromHost(host) {
+ return host.match(/.*?(\w+\.\w+)$/)[1];
+ },
+ getPublicSuffix() {},
+ },
+ io: {
+ newURI: spec => ({
+ mutate: () => ({
+ setRef: ref => ({
+ finalize: () => ({
+ ref,
+ spec,
+ }),
+ }),
+ }),
+ spec,
+ }),
+ },
+ search: {
+ init() {
+ return Promise.resolve();
+ },
+ getVisibleEngines: () =>
+ Promise.resolve([{ identifier: "google" }, { identifier: "bing" }]),
+ defaultEngine: {
+ identifier: "google",
+ searchForm:
+ "https://www.google.com/search?q=&ie=utf-8&oe=utf-8&client=firefox-b",
+ aliases: ["@google"],
+ },
+ defaultPrivateEngine: {
+ identifier: "bing",
+ searchForm: "https://www.bing.com",
+ aliases: ["@bing"],
+ },
+ getEngineByAlias: async () => null,
+ },
+ scriptSecurityManager: {
+ createNullPrincipal() {},
+ getSystemPrincipal() {},
+ },
+ wm: {
+ getMostRecentWindow: () => window,
+ getMostRecentBrowserWindow: () => window,
+ getEnumerator: () => [],
+ },
+ ww: { registerNotification() {}, unregisterNotification() {} },
+ appinfo: { appBuildID: "20180710100040", version: "69.0a1" },
+ scriptloader: { loadSubScript: () => {} },
+ startup: {
+ getStartupInfo() {
+ return {
+ process: {
+ getTime() {
+ return 1588010448000;
+ },
+ },
+ };
+ },
+ },
+ },
+ XPCOMUtils: {
+ defineLazyGetter(object, name, f) {
+ updateGlobalOrObject(object)[name] = f();
+ },
+ defineLazyGlobalGetters: updateGlobalOrObject,
+ defineLazyModuleGetter: updateGlobalOrObject,
+ defineLazyModuleGetters: updateGlobalOrObject,
+ defineLazyServiceGetter: updateGlobalOrObject,
+ defineLazyServiceGetters: updateGlobalOrObject,
+ defineLazyPreferenceGetter(object, name) {
+ updateGlobalOrObject(object)[name] = "";
+ },
+ generateQI() {
+ return {};
+ },
+ },
+ EventEmitter,
+ ShellService: {
+ doesAppNeedPin: () => false,
+ isDefaultBrowser: () => true,
+ },
+ FilterExpressions: {
+ eval() {
+ return Promise.resolve(false);
+ },
+ },
+ RemoteSettings,
+ Localization: class {
+ async formatMessages(stringsIds) {
+ return Promise.resolve(
+ stringsIds.map(({ id, args }) => ({ value: { string_id: id, args } }))
+ );
+ }
+ async formatValue(stringId) {
+ return Promise.resolve(stringId);
+ }
+ },
+ FxAccountsConfig: {
+ promiseConnectAccountURI(id) {
+ return Promise.resolve(id);
+ },
+ },
+ FX_MONITOR_OAUTH_CLIENT_ID: "fake_client_id",
+ ExperimentAPI: {
+ getExperiment() {},
+ getExperimentMetaData() {},
+ getRolloutMetaData() {},
+ },
+ NimbusFeatures: {
+ glean: {
+ getVariable() {},
+ },
+ newtab: {
+ getVariable() {},
+ getAllVariables() {},
+ onUpdate() {},
+ offUpdate() {},
+ },
+ pocketNewtab: {
+ getVariable() {},
+ getAllVariables() {},
+ onUpdate() {},
+ offUpdate() {},
+ },
+ cookieBannerHandling: {
+ getVariable() {},
+ },
+ },
+ TelemetryEnvironment: {
+ setExperimentActive() {},
+ currentEnvironment: {
+ profile: {
+ creationDate: 16587,
+ },
+ settings: {},
+ },
+ },
+ TelemetryStopwatch: {
+ start: () => {},
+ finish: () => {},
+ },
+ Sampling: {
+ ratioSample(seed, ratios) {
+ return Promise.resolve(0);
+ },
+ },
+ BrowserHandler: {
+ get kiosk() {
+ return false;
+ },
+ },
+ TelemetrySession: {
+ getMetadata(reason) {
+ return {
+ reason,
+ sessionId: "fake_session_id",
+ };
+ },
+ },
+ PageThumbs: {
+ addExpirationFilter() {},
+ removeExpirationFilter() {},
+ },
+ Logger: FakeLogger,
+ getFxAccountsSingleton() {},
+ AboutNewTab: {},
+ Glean: {
+ newtab: {
+ opened: {
+ record() {},
+ },
+ closed: {
+ record() {},
+ },
+ locale: {
+ set() {},
+ },
+ newtabCategory: {
+ set() {},
+ },
+ homepageCategory: {
+ set() {},
+ },
+ blockedSponsors: {
+ set() {},
+ },
+ },
+ newtabSearch: {
+ enabled: {
+ set() {},
+ },
+ },
+ pocket: {
+ enabled: {
+ set() {},
+ },
+ impression: {
+ record() {},
+ },
+ isSignedIn: {
+ set() {},
+ },
+ sponsoredStoriesEnabled: {
+ set() {},
+ },
+ click: {
+ record() {},
+ },
+ save: {
+ record() {},
+ },
+ topicClick: {
+ record() {},
+ },
+ },
+ topsites: {
+ enabled: {
+ set() {},
+ },
+ sponsoredEnabled: {
+ set() {},
+ },
+ impression: {
+ record() {},
+ },
+ click: {
+ record() {},
+ },
+ rows: {
+ set() {},
+ },
+ },
+ },
+ GleanPings: {
+ newtab: {
+ submit() {},
+ },
+ },
+ Utils: {
+ SERVER_URL: "bogus://foo",
+ },
+};
+overrider.set(TEST_GLOBAL);
+
+describe("activity-stream", () => {
+ after(() => overrider.restore());
+ files.forEach(file => req(file));
+});
diff --git a/browser/components/newtab/test/unit/utils.js b/browser/components/newtab/test/unit/utils.js
new file mode 100644
index 0000000000..22069b8635
--- /dev/null
+++ b/browser/components/newtab/test/unit/utils.js
@@ -0,0 +1,406 @@
+/**
+ * GlobalOverrider - Utility that allows you to override properties on the global object.
+ * See unit-entry.js for example usage.
+ */
+export class GlobalOverrider {
+ constructor() {
+ this.originalGlobals = new Map();
+ this.sandbox = sinon.createSandbox();
+ }
+
+ /**
+ * _override - Internal method to override properties on the global object.
+ * The first time a given key is overridden, we cache the original
+ * value in this.originalGlobals so that later it can be restored.
+ *
+ * @param {string} key The identifier of the property
+ * @param {any} value The value to which the property should be reassigned
+ */
+ _override(key, value) {
+ if (!this.originalGlobals.has(key)) {
+ this.originalGlobals.set(key, global[key]);
+ }
+ global[key] = value;
+ }
+
+ /**
+ * set - Override a given property, or all properties on an object
+ *
+ * @param {string|object} key If a string, the identifier of the property
+ * If an object, a number of properties and values to which they should be reassigned.
+ * @param {any} value The value to which the property should be reassigned
+ * @return {type} description
+ */
+ set(key, value) {
+ if (!value && typeof key === "object") {
+ const overrides = key;
+ Object.keys(overrides).forEach(k => this._override(k, overrides[k]));
+ } else {
+ this._override(key, value);
+ }
+ return value;
+ }
+
+ /**
+ * reset - Reset the global sandbox, so all state on spies, stubs etc. is cleared.
+ * You probably want to call this after each test.
+ */
+ reset() {
+ this.sandbox.reset();
+ }
+
+ /**
+ * restore - Restore the global sandbox and reset all overriden properties to
+ * their original values. You should call this after all tests have completed.
+ */
+ restore() {
+ this.sandbox.restore();
+ this.originalGlobals.forEach((value, key) => {
+ global[key] = value;
+ });
+ }
+}
+
+/**
+ * A map of mocked preference names and values, used by `FakensIPrefBranch`,
+ * `FakensIPrefService`, and `FakePrefs`.
+ *
+ * Tests should add entries to this map for any preferences they'd like to set,
+ * and remove any entries during teardown for preferences that shouldn't be
+ * shared between tests.
+ */
+export const FAKE_GLOBAL_PREFS = new Map();
+
+/**
+ * Very simple fake for the most basic semantics of nsIPrefBranch. Lots of
+ * things aren't yet supported. Feel free to add them in.
+ *
+ * @param {Object} args - optional arguments
+ * @param {Function} args.initHook - if present, will be called back
+ * inside the constructor. Typically used from tests
+ * to save off a pointer to the created instance so that
+ * stubs and spies can be inspected by the test code.
+ */
+export class FakensIPrefBranch {
+ PREF_INVALID = "invalid";
+ PREF_INT = "integer";
+ PREF_BOOL = "boolean";
+ PREF_STRING = "string";
+
+ constructor(args) {
+ if (args) {
+ if ("initHook" in args) {
+ args.initHook.call(this);
+ }
+ if (args.defaultBranch) {
+ this.prefs = new Map();
+ } else {
+ this.prefs = FAKE_GLOBAL_PREFS;
+ }
+ } else {
+ this.prefs = FAKE_GLOBAL_PREFS;
+ }
+ this._prefBranch = {};
+ this.observers = new Map();
+ }
+ addObserver(prefix, callback) {
+ this.observers.set(prefix, callback);
+ }
+ removeObserver(prefix, callback) {
+ this.observers.delete(prefix, callback);
+ }
+ setStringPref(prefName, value) {
+ this.set(prefName, value);
+ }
+ getStringPref(prefName, defaultValue) {
+ return this.get(prefName, defaultValue);
+ }
+ setBoolPref(prefName, value) {
+ this.set(prefName, value);
+ }
+ getBoolPref(prefName) {
+ return this.get(prefName);
+ }
+ setIntPref(prefName, value) {
+ this.set(prefName, value);
+ }
+ getIntPref(prefName) {
+ return this.get(prefName);
+ }
+ setCharPref(prefName, value) {
+ this.set(prefName, value);
+ }
+ getCharPref(prefName) {
+ return this.get(prefName);
+ }
+ clearUserPref(prefName) {
+ this.prefs.delete(prefName);
+ }
+ get(prefName, defaultValue) {
+ let value = this.prefs.get(prefName);
+ return typeof value === "undefined" ? defaultValue : value;
+ }
+ getPrefType(prefName) {
+ let value = this.prefs.get(prefName);
+ switch (typeof value) {
+ case "number":
+ return this.PREF_INT;
+
+ case "boolean":
+ return this.PREF_BOOL;
+
+ case "string":
+ return this.PREF_STRING;
+
+ default:
+ return this.PREF_INVALID;
+ }
+ }
+ set(prefName, value) {
+ this.prefs.set(prefName, value);
+
+ // Trigger all observers for prefixes of the changed pref name. This matches
+ // the semantics of `nsIPrefBranch`.
+ let observerPrefixes = [...this.observers.keys()].filter(prefix =>
+ prefName.startsWith(prefix)
+ );
+ for (let observerPrefix of observerPrefixes) {
+ this.observers.get(observerPrefix)("", "", prefName);
+ }
+ }
+ getChildList(prefix) {
+ return [...this.prefs.keys()].filter(prefName =>
+ prefName.startsWith(prefix)
+ );
+ }
+ prefHasUserValue(prefName) {
+ return this.prefs.has(prefName);
+ }
+ prefIsLocked(prefName) {
+ return false;
+ }
+}
+
+/**
+ * A fake `Services.prefs` implementation that extends `FakensIPrefBranch`
+ * with methods specific to `nsIPrefService`.
+ */
+export class FakensIPrefService extends FakensIPrefBranch {
+ getBranch() {}
+ getDefaultBranch(prefix) {
+ return {
+ setBoolPref() {},
+ setIntPref() {},
+ setStringPref() {},
+ clearUserPref() {},
+ };
+ }
+}
+
+/**
+ * Very simple fake for the most basic semantics of Preferences.sys.mjs.
+ * Extends FakensIPrefBranch.
+ */
+export class FakePrefs extends FakensIPrefBranch {
+ observe(prefName, callback) {
+ super.addObserver(prefName, callback);
+ }
+ ignore(prefName, callback) {
+ super.removeObserver(prefName, callback);
+ }
+ observeBranch(listener) {}
+ ignoreBranch(listener) {}
+ set(prefName, value) {
+ this.prefs.set(prefName, value);
+
+ // Trigger observers for just the changed pref name, not any of its
+ // prefixes. This matches the semantics of `Preferences.sys.mjs`.
+ if (this.observers.has(prefName)) {
+ this.observers.get(prefName)(value);
+ }
+ }
+}
+
+/**
+ * Slimmed down version of toolkit/modules/EventEmitter.sys.mjs
+ */
+export function EventEmitter() {}
+EventEmitter.decorate = function (objectToDecorate) {
+ let emitter = new EventEmitter();
+ objectToDecorate.on = emitter.on.bind(emitter);
+ objectToDecorate.off = emitter.off.bind(emitter);
+ objectToDecorate.once = emitter.once.bind(emitter);
+ objectToDecorate.emit = emitter.emit.bind(emitter);
+};
+EventEmitter.prototype = {
+ on(event, listener) {
+ if (!this._eventEmitterListeners) {
+ this._eventEmitterListeners = new Map();
+ }
+ if (!this._eventEmitterListeners.has(event)) {
+ this._eventEmitterListeners.set(event, []);
+ }
+ this._eventEmitterListeners.get(event).push(listener);
+ },
+ off(event, listener) {
+ if (!this._eventEmitterListeners) {
+ return;
+ }
+ let listeners = this._eventEmitterListeners.get(event);
+ if (listeners) {
+ this._eventEmitterListeners.set(
+ event,
+ listeners.filter(
+ l => l !== listener && l._originalListener !== listener
+ )
+ );
+ }
+ },
+ once(event, listener) {
+ return new Promise(resolve => {
+ let handler = (_, first, ...rest) => {
+ this.off(event, handler);
+ if (listener) {
+ listener(event, first, ...rest);
+ }
+ resolve(first);
+ };
+
+ handler._originalListener = listener;
+ this.on(event, handler);
+ });
+ },
+ // All arguments to this method will be sent to listeners
+ emit(event, ...args) {
+ if (
+ !this._eventEmitterListeners ||
+ !this._eventEmitterListeners.has(event)
+ ) {
+ return;
+ }
+ let originalListeners = this._eventEmitterListeners.get(event);
+ for (let listener of this._eventEmitterListeners.get(event)) {
+ // If the object was destroyed during event emission, stop
+ // emitting.
+ if (!this._eventEmitterListeners) {
+ break;
+ }
+ // If listeners were removed during emission, make sure the
+ // event handler we're going to fire wasn't removed.
+ if (
+ originalListeners === this._eventEmitterListeners.get(event) ||
+ this._eventEmitterListeners.get(event).some(l => l === listener)
+ ) {
+ try {
+ listener(event, ...args);
+ } catch (ex) {
+ // error with a listener
+ }
+ }
+ }
+ },
+};
+
+export function FakePerformance() {}
+FakePerformance.prototype = {
+ marks: new Map(),
+ now() {
+ return window.performance.now();
+ },
+ timing: { navigationStart: 222222.123 },
+ get timeOrigin() {
+ return 10000.234;
+ },
+ // XXX assumes type == "mark"
+ getEntriesByName(name, type) {
+ if (this.marks.has(name)) {
+ return this.marks.get(name);
+ }
+ return [];
+ },
+ callsToMark: 0,
+
+ /**
+ * @note The "startTime" for each mark is simply the number of times mark
+ * has been called in this object.
+ */
+ mark(name) {
+ let markObj = {
+ name,
+ entryType: "mark",
+ startTime: ++this.callsToMark,
+ duration: 0,
+ };
+
+ if (this.marks.has(name)) {
+ this.marks.get(name).push(markObj);
+ return;
+ }
+
+ this.marks.set(name, [markObj]);
+ },
+};
+
+/**
+ * addNumberReducer - a simple dummy reducer for testing that adds a number
+ */
+export function addNumberReducer(prevState = 0, action) {
+ return action.type === "ADD" ? prevState + action.data : prevState;
+}
+
+export class FakeConsoleAPI {
+ static LOG_LEVELS = {
+ all: Number.MIN_VALUE,
+ debug: 2,
+ log: 3,
+ info: 3,
+ clear: 3,
+ trace: 3,
+ timeEnd: 3,
+ time: 3,
+ assert: 3,
+ group: 3,
+ groupEnd: 3,
+ profile: 3,
+ profileEnd: 3,
+ dir: 3,
+ dirxml: 3,
+ warn: 4,
+ error: 5,
+ off: Number.MAX_VALUE,
+ };
+
+ constructor({ prefix = "", maxLogLevel = "all" } = {}) {
+ this.prefix = prefix;
+ this.prefixStr = prefix ? `${prefix}: ` : "";
+ this.maxLogLevel = maxLogLevel;
+
+ for (const level of Object.keys(FakeConsoleAPI.LOG_LEVELS)) {
+ // eslint-disable-next-line no-console
+ if (typeof console[level] === "function") {
+ this[level] = this.shouldLog(level)
+ ? this._log.bind(this, level)
+ : () => {};
+ }
+ }
+ }
+ shouldLog(level) {
+ return (
+ FakeConsoleAPI.LOG_LEVELS[this.maxLogLevel] <=
+ FakeConsoleAPI.LOG_LEVELS[level]
+ );
+ }
+ _log(level, ...args) {
+ console[level](this.prefixStr, ...args); // eslint-disable-line no-console
+ }
+}
+
+export class FakeLogger extends FakeConsoleAPI {
+ constructor() {
+ super({
+ // Don't use a prefix because the first instance gets cached and reused by
+ // other consumers that would otherwise pass their own identifying prefix.
+ maxLogLevel: "off", // Change this to "debug" or "all" to get more logging in tests
+ });
+ }
+}
diff --git a/browser/components/newtab/test/xpcshell/ds_layout.json b/browser/components/newtab/test/xpcshell/ds_layout.json
new file mode 100644
index 0000000000..4193fa635d
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/ds_layout.json
@@ -0,0 +1,89 @@
+{
+ "spocs": {
+ "url": ""
+ },
+ "layout": [
+ {
+ "width": 12,
+ "components": [
+ {
+ "type": "TopSites",
+ "header": {
+ "title": "Top Sites"
+ },
+ "properties": null
+ },
+ {
+ "type": "Message",
+ "header": {
+ "title": "Recommended by Pocket",
+ "subtitle": "",
+ "link_text": "How it works",
+ "link_url": "https://getpocket.com/firefox/new_tab_learn_more",
+ "icon": "chrome://global/skin/icons/pocket.svg"
+ },
+ "properties": null,
+ "styles": {
+ ".ds-message": "margin-bottom: -20px"
+ }
+ },
+ {
+ "type": "CardGrid",
+ "properties": {
+ "items": 3
+ },
+ "header": {
+ "title": ""
+ },
+ "feed": {
+ "embed_reference": null,
+ "url": "http://example.com/topstories.json"
+ },
+ "spocs": {
+ "probability": 1,
+ "positions": [
+ {
+ "index": 2
+ }
+ ]
+ }
+ },
+ {
+ "type": "Navigation",
+ "properties": {
+ "alignment": "left-align",
+ "links": [
+ {
+ "name": "Must Reads",
+ "url": "https://getpocket.com/explore/must-reads?src=fx_new_tab"
+ },
+ {
+ "name": "Productivity",
+ "url": "https://getpocket.com/explore/productivity?src=fx_new_tab"
+ },
+ {
+ "name": "Health",
+ "url": "https://getpocket.com/explore/health?src=fx_new_tab"
+ },
+ {
+ "name": "Finance",
+ "url": "https://getpocket.com/explore/finance?src=fx_new_tab"
+ },
+ {
+ "name": "Technology",
+ "url": "https://getpocket.com/explore/technology?src=fx_new_tab"
+ },
+ {
+ "name": "More Recommendations ›",
+ "url": "https://getpocket.com/explore/trending?src=fx_new_tab"
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ],
+ "feeds": {},
+ "error": 0,
+ "status": 1
+}
diff --git a/browser/components/newtab/test/xpcshell/head.js b/browser/components/newtab/test/xpcshell/head.js
new file mode 100644
index 0000000000..49463fe0a8
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/head.js
@@ -0,0 +1,105 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* eslint-disable no-unused-vars */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
+
+function assertValidates(validator, obj, msg) {
+ const result = validator.validate(obj);
+ Assert.ok(
+ result.valid && result.errors.length === 0,
+ `${msg} - errors = ${JSON.stringify(result.errors, undefined, 2)}`
+ );
+}
+
+async function fetchSchema(uri) {
+ try {
+ return fetch(uri, { credentials: "omit" }).then(rsp => rsp.json());
+ } catch (e) {
+ throw new Error(`Could not fetch ${uri}`);
+ }
+}
+
+async function schemaValidatorFor(uri, { common = false } = {}) {
+ const schema = await fetchSchema(uri);
+ const validator = new lazy.JsonSchema.Validator(schema);
+
+ if (common) {
+ const commonSchema = await fetchSchema(
+ "resource://testing-common/FxMSCommon.schema.json"
+ );
+ validator.addSchema(commonSchema);
+ }
+
+ return validator;
+}
+
+async function makeValidators() {
+ const experimentValidator = await schemaValidatorFor(
+ "resource://activity-stream/schemas/MessagingExperiment.schema.json"
+ );
+
+ const messageValidators = {
+ cfr_doorhanger: await schemaValidatorFor(
+ "resource://testing-common/ExtensionDoorhanger.schema.json",
+ { common: true }
+ ),
+ cfr_urlbar_chiclet: await schemaValidatorFor(
+ "resource://testing-common/CFRUrlbarChiclet.schema.json",
+ { common: true }
+ ),
+ infobar: await schemaValidatorFor(
+ "resource://testing-common/InfoBar.schema.json",
+ { common: true }
+ ),
+ pb_newtab: await schemaValidatorFor(
+ "resource://testing-common/NewtabPromoMessage.schema.json",
+ { common: true }
+ ),
+ protections_panel: await schemaValidatorFor(
+ "resource://testing-common/ProtectionsPanelMessage.schema.json",
+ { common: true }
+ ),
+ spotlight: await schemaValidatorFor(
+ "resource://testing-common/Spotlight.schema.json",
+ { common: true }
+ ),
+ toast_notification: await schemaValidatorFor(
+ "resource://testing-common/ToastNotification.schema.json",
+ { common: true }
+ ),
+ toolbar_badge: await schemaValidatorFor(
+ "resource://testing-common/ToolbarBadgeMessage.schema.json",
+ { common: true }
+ ),
+ update_action: await schemaValidatorFor(
+ "resource://testing-common/UpdateAction.schema.json",
+ { common: true }
+ ),
+ whatsnew_panel_message: await schemaValidatorFor(
+ "resource://testing-common/WhatsNewMessage.schema.json",
+ { common: true }
+ ),
+ feature_callout: await schemaValidatorFor(
+ // For now, Feature Callout and Spotlight share a common schema
+ "resource://testing-common/Spotlight.schema.json",
+ { common: true }
+ ),
+ };
+
+ messageValidators.milestone_message = messageValidators.cfr_doorhanger;
+
+ return { experimentValidator, messageValidators };
+}
diff --git a/browser/components/newtab/test/xpcshell/test_ASRouterTargeting_attribution.js b/browser/components/newtab/test/xpcshell/test_ASRouterTargeting_attribution.js
new file mode 100644
index 0000000000..f2b473144b
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_ASRouterTargeting_attribution.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AttributionCode } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+const { ASRouterTargeting } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouterTargeting.jsm"
+);
+const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+);
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+add_task(async function check_attribution_data() {
+ // Some setup to fake the correct attribution data
+ const appPath = MacAttribution.applicationPath;
+ const attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ const campaign = "non-fx-button";
+ const source = "addons.mozilla.org";
+ const referrer = `https://allizom.org/anything/?utm_campaign=${campaign}&utm_source=${source}`;
+ attributionSvc.setReferrerUrl(appPath, referrer, true);
+ AttributionCode._clearCache();
+ await AttributionCode.getAttrDataAsync();
+
+ const { campaign: attributionCampain, source: attributionSource } =
+ ASRouterTargeting.Environment.attributionData;
+ equal(
+ attributionCampain,
+ campaign,
+ "should get the correct campaign out of attributionData"
+ );
+ equal(
+ attributionSource,
+ source,
+ "should get the correct source out of attributionData"
+ );
+
+ const messages = [
+ {
+ id: "foo1",
+ targeting:
+ "attributionData.campaign == 'back_to_school' && attributionData.source == 'addons.mozilla.org'",
+ },
+ {
+ id: "foo2",
+ targeting:
+ "attributionData.campaign == 'non-fx-button' && attributionData.source == 'addons.mozilla.org'",
+ },
+ ];
+
+ equal(
+ await ASRouterTargeting.findMatchingMessage({ messages }),
+ messages[1],
+ "should select the message with the correct campaign and source"
+ );
+ AttributionCode._clearCache();
+});
+
+add_task(async function check_enterprise_targeting() {
+ const messages = [
+ {
+ id: "foo1",
+ targeting: "hasActiveEnterprisePolicies",
+ },
+ {
+ id: "foo2",
+ targeting: "!hasActiveEnterprisePolicies",
+ },
+ ];
+
+ equal(
+ await ASRouterTargeting.findMatchingMessage({ messages }),
+ messages[1],
+ "should select the message for policies turned off"
+ );
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ DisableFirefoxStudies: {
+ Value: true,
+ },
+ },
+ });
+
+ equal(
+ await ASRouterTargeting.findMatchingMessage({ messages }),
+ messages[0],
+ "should select the message for policies turned on"
+ );
+});
diff --git a/browser/components/newtab/test/xpcshell/test_ASRouterTargeting_snapshot.js b/browser/components/newtab/test/xpcshell/test_ASRouterTargeting_snapshot.js
new file mode 100644
index 0000000000..cb5a13baf5
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_ASRouterTargeting_snapshot.js
@@ -0,0 +1,138 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { ASRouterTargeting } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouterTargeting.jsm"
+);
+
+add_task(async function should_ignore_rejections() {
+ let target = {
+ get foo() {
+ return new Promise(resolve => resolve(1));
+ },
+
+ get bar() {
+ return new Promise((resolve, reject) => reject(new Error("unspecified")));
+ },
+ };
+
+ let snapshot = await ASRouterTargeting.getEnvironmentSnapshot(target);
+ Assert.deepEqual(snapshot, { environment: { foo: 1 }, version: 1 });
+});
+
+add_task(async function nested_objects() {
+ const target = {
+ get foo() {
+ return Promise.resolve("foo");
+ },
+ get bar() {
+ return Promise.reject(new Error("bar"));
+ },
+ baz: {
+ get qux() {
+ return Promise.resolve("qux");
+ },
+ get quux() {
+ return Promise.reject(new Error("quux"));
+ },
+ get corge() {
+ return {
+ get grault() {
+ return Promise.resolve("grault");
+ },
+ get garply() {
+ return Promise.reject(new Error("garply"));
+ },
+ };
+ },
+ },
+ };
+
+ const snapshot = await ASRouterTargeting.getEnvironmentSnapshot(target);
+ Assert.deepEqual(
+ snapshot,
+ {
+ environment: {
+ foo: "foo",
+ baz: {
+ qux: "qux",
+ corge: {
+ grault: "grault",
+ },
+ },
+ },
+ version: 1,
+ },
+ "getEnvironmentSnapshot should resolve nested promises"
+ );
+});
+
+add_task(async function arrays() {
+ const target = {
+ foo: [1, 2, 3],
+ bar: [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)],
+ baz: Promise.resolve([1, 2, 3]),
+ qux: Promise.resolve([
+ Promise.resolve(1),
+ Promise.resolve(2),
+ Promise.resolve(3),
+ ]),
+ quux: Promise.resolve({
+ corge: [Promise.resolve(1), 2, 3],
+ }),
+ };
+
+ const snapshot = await ASRouterTargeting.getEnvironmentSnapshot(target);
+ Assert.deepEqual(
+ snapshot,
+ {
+ environment: {
+ foo: [1, 2, 3],
+ bar: [1, 2, 3],
+ baz: [1, 2, 3],
+ qux: [1, 2, 3],
+ quux: { corge: [1, 2, 3] },
+ },
+ version: 1,
+ },
+ "getEnvironmentSnapshot should resolve arrays correctly"
+ );
+});
+
+/*
+ * NB: This test is last because it manipulates shutdown phases.
+ *
+ * Adding tests after this one will result in failures.
+ */
+add_task(async function should_ignore_rejections() {
+ // The order that `ASRouterTargeting.getEnvironmentSnapshot`
+ // enumerates the target object matters here, but it's guaranteed to
+ // be consistent by the `for ... in` ordering: see
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in#description.
+ let target = {
+ get foo() {
+ return new Promise(resolve => resolve(1));
+ },
+
+ get bar() {
+ return new Promise(resolve => {
+ // Pretend that we're about to shut down.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWN
+ );
+ resolve(2);
+ });
+ },
+
+ get baz() {
+ return new Promise(resolve => resolve(3));
+ },
+ };
+
+ let snapshot = await ASRouterTargeting.getEnvironmentSnapshot(target);
+ // `baz` is dropped since we're shutting down by the time it's processed.
+ Assert.deepEqual(snapshot, { environment: { foo: 1, bar: 2 }, version: 1 });
+});
diff --git a/browser/components/newtab/test/xpcshell/test_ASRouter_getTargetingParameters.js b/browser/components/newtab/test/xpcshell/test_ASRouter_getTargetingParameters.js
new file mode 100644
index 0000000000..fb3b037660
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_ASRouter_getTargetingParameters.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { ASRouter } = ChromeUtils.import(
+ "resource://activity-stream/lib/ASRouter.jsm"
+);
+
+add_task(async function nested_objects() {
+ const target = {
+ get foo() {
+ return Promise.resolve("foo");
+ },
+ baz: {
+ get qux() {
+ return Promise.resolve("qux");
+ },
+ get corge() {
+ return {
+ get grault() {
+ return Promise.resolve("grault");
+ },
+ };
+ },
+ },
+ };
+
+ const params = await ASRouter.getTargetingParameters(target);
+ Assert.deepEqual(
+ params,
+ {
+ foo: "foo",
+ baz: {
+ qux: "qux",
+ corge: {
+ grault: "grault",
+ },
+ },
+ },
+ "getTargetingParameters should resolve nested promises"
+ );
+});
+
+add_task(async function arrays() {
+ const target = {
+ foo: [1, 2, 3],
+ bar: [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)],
+ baz: Promise.resolve([1, 2, 3]),
+ qux: Promise.resolve([
+ Promise.resolve(1),
+ Promise.resolve(2),
+ Promise.resolve(3),
+ ]),
+ quux: Promise.resolve({
+ corge: [Promise.resolve(1), 2, 3],
+ }),
+ };
+
+ const params = await ASRouter.getTargetingParameters(target);
+ Assert.deepEqual(
+ params,
+ {
+ foo: [1, 2, 3],
+ bar: [1, 2, 3],
+ baz: [1, 2, 3],
+ qux: [1, 2, 3],
+ quux: { corge: [1, 2, 3] },
+ },
+ "getEnvironmentSnapshot should resolve arrays correctly"
+ );
+});
diff --git a/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js b/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js
new file mode 100644
index 0000000000..a0cb2cf324
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AboutHomeStartupCacheChild } = ChromeUtils.import(
+ "resource:///modules/AboutNewTabService.jsm"
+);
+
+/**
+ * Tests that AboutHomeStartupCacheChild will terminate its PromiseWorker
+ * on memory-pressure, and that a new PromiseWorker can then be generated on
+ * demand.
+ */
+add_task(async function test_memory_pressure() {
+ AboutHomeStartupCacheChild.init();
+
+ let worker = AboutHomeStartupCacheChild.getOrCreateWorker();
+ Assert.ok(worker, "Should have been able to get the worker.");
+
+ Assert.equal(
+ worker,
+ AboutHomeStartupCacheChild.getOrCreateWorker(),
+ "The worker is cached and re-usable."
+ );
+
+ Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize");
+
+ let newWorker = AboutHomeStartupCacheChild.getOrCreateWorker();
+ Assert.notEqual(worker, newWorker, "Old worker should have been replaced.");
+
+ AboutHomeStartupCacheChild.uninit();
+});
diff --git a/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js b/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js
new file mode 100644
index 0000000000..0cbb81351b
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js
@@ -0,0 +1,251 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test ensures that the about:home startup cache worker
+ * script can correctly convert a state object from the Activity
+ * Stream Redux store into an HTML document and script.
+ */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { SearchTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SearchTestUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+SearchTestUtils.init(this);
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+const { AboutNewTab } = ChromeUtils.import(
+ "resource:///modules/AboutNewTab.jsm"
+);
+const { PREFS_CONFIG } = ChromeUtils.import(
+ "resource://activity-stream/lib/ActivityStream.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs",
+});
+
+const CACHE_WORKER_URL = "resource://activity-stream/lib/cache-worker.js";
+const NEWTAB_RENDER_URL =
+ "resource://activity-stream/data/content/newtab-render.js";
+
+/**
+ * In order to make this test less brittle, much of Activity Stream is
+ * initialized here in order to generate a state object at runtime, rather
+ * than hard-coding one in. This requires quite a bit of machinery in order
+ * to work properly. Specifically, we need to launch an HTTP server to serve
+ * a dynamic layout, and then have that layout point to a local feed rather
+ * than one from the Pocket CDN.
+ */
+add_setup(async function () {
+ do_get_profile();
+ // The SearchService is also needed in order to construct the initial state,
+ // which means that the AddonManager needs to be available.
+ await AddonTestUtils.promiseStartupManager();
+
+ // The example.com domain will be used to host the dynamic layout JSON and
+ // the top stories JSON.
+ let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+ server.registerDirectory("/", do_get_cwd());
+
+ // Top Stories are disabled by default in our testing profiles.
+ Services.prefs.setBoolPref(
+ "browser.newtabpage.activity-stream.feeds.section.topstories",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "browser.newtabpage.activity-stream.feeds.system.topstories",
+ true
+ );
+
+ let defaultDSConfig = JSON.parse(
+ PREFS_CONFIG.get("discoverystream.config").getValue({
+ geo: "US",
+ locale: "en-US",
+ })
+ );
+
+ let newConfig = Object.assign(defaultDSConfig, {
+ show_spocs: false,
+ hardcoded_layout: false,
+ layout_endpoint: "http://example.com/ds_layout.json",
+ });
+
+ // 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(newConfig)
+ );
+
+ // We need to allow example.com as a place to get both the layout and the
+ // top stories from.
+ Services.prefs.setCharPref(
+ "browser.newtabpage.activity-stream.discoverystream.endpoints",
+ `http://example.com`
+ );
+
+ Services.prefs.setBoolPref(
+ "browser.newtabpage.activity-stream.telemetry.structuredIngestion",
+ false
+ );
+ Services.prefs.setBoolPref("browser.ping-centre.telemetry", false);
+
+ // We need a default search engine set up for rendering the search input.
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "Test engine",
+ keyword: "@testengine",
+ search_url_get_params: "s={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+
+ // Initialize Activity Stream, and pretend that a new window has been loaded
+ // to kick off initializing all of the feeds.
+ AboutNewTab.init();
+ 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;
+ });
+});
+
+/**
+ * Gets the Activity Stream Redux state from Activity Stream and sends it
+ * into an instance of the cache worker to ensure that the resulting markup
+ * and script makes sense.
+ */
+add_task(async function test_cache_worker() {
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads");
+ });
+
+ let state = AboutNewTab.activityStream.store.getState();
+
+ let cacheWorker = new BasePromiseWorker(CACHE_WORKER_URL);
+ let { page, script } = await cacheWorker.post("construct", [state]);
+ ok(!!page.length, "Got page content");
+ ok(!!script.length, "Got script content");
+
+ // The template strings should have been replaced.
+ equal(
+ page.indexOf("{{ MARKUP }}"),
+ -1,
+ "Page template should have {{ MARKUP }} replaced"
+ );
+ equal(
+ page.indexOf("{{ CACHE_TIME }}"),
+ -1,
+ "Page template should have {{ CACHE_TIME }} replaced"
+ );
+ equal(
+ script.indexOf("{{ STATE }}"),
+ -1,
+ "Script template should have {{ STATE }} replaced"
+ );
+
+ // Now let's make sure that the generated script makes sense. We'll
+ // evaluate it in a sandbox to make sure broken JS doesn't break the
+ // test.
+ let sandbox = Cu.Sandbox(Cu.getGlobalForObject({}));
+ let passedState = null;
+
+ // window.NewtabRenderUtils.renderCache is the exposed API from
+ // activity-stream.jsx that the script is expected to call to hydrate
+ // the pre-rendered markup. We'll implement that, and use that to ensure
+ // that the passed in state object matches the state we sent into the
+ // worker.
+ sandbox.window = {
+ NewtabRenderUtils: {
+ renderCache(aState) {
+ passedState = aState;
+ },
+ },
+ };
+ Cu.evalInSandbox(script, sandbox);
+
+ // The NEWTAB_RENDER_URL script is what ultimately causes the state
+ // to be passed into the renderCache function.
+ Services.scriptloader.loadSubScript(NEWTAB_RENDER_URL, sandbox);
+
+ equal(
+ sandbox.window.__FROM_STARTUP_CACHE__,
+ true,
+ "Should have set __FROM_STARTUP_CACHE__ to true"
+ );
+
+ // The worker is expected to modify the state slightly before running
+ // it through ReactDOMServer by setting App.isForStartupCache to true.
+ // This allows React components to change their behaviour if the cache
+ // is being generated.
+ state.App.isForStartupCache = true;
+
+ // Some of the properties on the state might have values set to undefined.
+ // There is no way to express a named undefined property on an object in
+ // JSON, so we filter those out by stringifying and re-parsing.
+ state = JSON.parse(JSON.stringify(state));
+
+ Assert.deepEqual(
+ passedState,
+ state,
+ "Should have called renderCache with the expected state"
+ );
+
+ // Now let's do a quick smoke-test on the markup to ensure that the
+ // one Top Story from topstories.json is there.
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(page, "text/html");
+ let root = doc.getElementById("root");
+ ok(root.childElementCount, "There are children on the root node");
+
+ // There should be the 1 top story, and 2 placeholders.
+ equal(
+ Array.from(root.querySelectorAll(".ds-card")).length,
+ 3,
+ "There are 3 DSCards"
+ );
+ let cardHostname = doc.querySelector(
+ "[data-section-id='topstories'] .source"
+ ).innerText;
+ equal(cardHostname, "bbc.com", "Card hostname is bbc.com");
+
+ let placeholders = doc.querySelectorAll(".ds-card.placeholder");
+ equal(placeholders.length, 2, "There should be 2 placeholders");
+});
+
+/**
+ * Tests that if the cache-worker construct method throws an exception
+ * that the construct Promise still resolves. Passing a null state should
+ * be enough to get it to throw.
+ */
+add_task(async function test_cache_worker_exception() {
+ let cacheWorker = new BasePromiseWorker(CACHE_WORKER_URL);
+ let { page, script } = await cacheWorker.post("construct", [null]);
+ equal(page, null, "Should have gotten a null page nsIInputStream");
+ equal(script, null, "Should have gotten a null script nsIInputStream");
+});
diff --git a/browser/components/newtab/test/xpcshell/test_AboutNewTab.js b/browser/components/newtab/test/xpcshell/test_AboutNewTab.js
new file mode 100644
index 0000000000..9b31a2add1
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_AboutNewTab.js
@@ -0,0 +1,359 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+/**
+ * This file tests both the AboutNewTab and nsIAboutNewTabService
+ * for its default URL values, as well as its behaviour when overriding
+ * the default URL values.
+ */
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { AboutNewTab } = ChromeUtils.import(
+ "resource:///modules/AboutNewTab.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "aboutNewTabService",
+ "@mozilla.org/browser/aboutnewtab-service;1",
+ "nsIAboutNewTabService"
+);
+
+AboutNewTab.init();
+
+const IS_RELEASE_OR_BETA = AppConstants.RELEASE_OR_BETA;
+
+const DOWNLOADS_URL =
+ "chrome://browser/content/downloads/contentAreaDownloadsView.xhtml";
+const SEPARATE_PRIVILEGED_CONTENT_PROCESS_PREF =
+ "browser.tabs.remote.separatePrivilegedContentProcess";
+const ACTIVITY_STREAM_DEBUG_PREF = "browser.newtabpage.activity-stream.debug";
+const SIMPLIFIED_WELCOME_ENABLED_PREF = "browser.aboutwelcome.enabled";
+
+function cleanup() {
+ Services.prefs.clearUserPref(SEPARATE_PRIVILEGED_CONTENT_PROCESS_PREF);
+ Services.prefs.clearUserPref(ACTIVITY_STREAM_DEBUG_PREF);
+ Services.prefs.clearUserPref(SIMPLIFIED_WELCOME_ENABLED_PREF);
+ AboutNewTab.resetNewTabURL();
+}
+
+registerCleanupFunction(cleanup);
+
+let ACTIVITY_STREAM_URL;
+let ACTIVITY_STREAM_DEBUG_URL;
+
+function setExpectedUrlsWithScripts() {
+ ACTIVITY_STREAM_URL =
+ "resource://activity-stream/prerendered/activity-stream.html";
+ ACTIVITY_STREAM_DEBUG_URL =
+ "resource://activity-stream/prerendered/activity-stream-debug.html";
+}
+
+function setExpectedUrlsWithoutScripts() {
+ ACTIVITY_STREAM_URL =
+ "resource://activity-stream/prerendered/activity-stream-noscripts.html";
+
+ // Debug urls are the same as non-debug because debug scripts load dynamically
+ ACTIVITY_STREAM_DEBUG_URL = ACTIVITY_STREAM_URL;
+}
+
+function nextChangeNotificationPromise(aNewURL, testMessage) {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(observer, aTopic);
+ Assert.equal(aData, aNewURL, testMessage);
+ resolve();
+ }, "newtab-url-changed");
+ });
+}
+
+function setPrivilegedContentProcessPref(usePrivilegedContentProcess) {
+ if (
+ usePrivilegedContentProcess === AboutNewTab.privilegedAboutProcessEnabled
+ ) {
+ return Promise.resolve();
+ }
+
+ let notificationPromise = nextChangeNotificationPromise("about:newtab");
+
+ Services.prefs.setBoolPref(
+ SEPARATE_PRIVILEGED_CONTENT_PROCESS_PREF,
+ usePrivilegedContentProcess
+ );
+ return notificationPromise;
+}
+
+// Default expected URLs to files with scripts in them.
+setExpectedUrlsWithScripts();
+
+function addTestsWithPrivilegedContentProcessPref(test) {
+ add_task(async () => {
+ await setPrivilegedContentProcessPref(true);
+ setExpectedUrlsWithoutScripts();
+ await test();
+ });
+ add_task(async () => {
+ await setPrivilegedContentProcessPref(false);
+ setExpectedUrlsWithScripts();
+ await test();
+ });
+}
+
+function setBoolPrefAndWaitForChange(pref, value, testMessage) {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(observer, aTopic);
+ Assert.equal(aData, AboutNewTab.newTabURL, testMessage);
+ resolve();
+ }, "newtab-url-changed");
+
+ Services.prefs.setBoolPref(pref, value);
+ });
+}
+
+add_task(async function test_as_initial_values() {
+ Assert.ok(
+ AboutNewTab.activityStreamEnabled,
+ ".activityStreamEnabled should be set to the correct initial value"
+ );
+ // This pref isn't defined on release or beta, so we fall back to false
+ Assert.equal(
+ AboutNewTab.activityStreamDebug,
+ Services.prefs.getBoolPref(ACTIVITY_STREAM_DEBUG_PREF, false),
+ ".activityStreamDebug should be set to the correct initial value"
+ );
+});
+
+/**
+ * Test the overriding of the default URL
+ */
+add_task(async function test_override_activity_stream_disabled() {
+ let notificationPromise;
+
+ Assert.ok(
+ !AboutNewTab.newTabURLOverridden,
+ "Newtab URL should not be overridden"
+ );
+ const ORIGINAL_URL = aboutNewTabService.defaultURL;
+
+ // override with some remote URL
+ let url = "http://example.com/";
+ notificationPromise = nextChangeNotificationPromise(url);
+ AboutNewTab.newTabURL = url;
+ await notificationPromise;
+ Assert.ok(AboutNewTab.newTabURLOverridden, "Newtab URL should be overridden");
+ Assert.ok(
+ !AboutNewTab.activityStreamEnabled,
+ "Newtab activity stream should not be enabled"
+ );
+ Assert.equal(
+ AboutNewTab.newTabURL,
+ url,
+ "Newtab URL should be the custom URL"
+ );
+ Assert.equal(
+ aboutNewTabService.defaultURL,
+ ORIGINAL_URL,
+ "AboutNewTabService defaultURL is unchanged"
+ );
+
+ // test reset with activity stream disabled
+ notificationPromise = nextChangeNotificationPromise("about:newtab");
+ AboutNewTab.resetNewTabURL();
+ await notificationPromise;
+ Assert.ok(
+ !AboutNewTab.newTabURLOverridden,
+ "Newtab URL should not be overridden"
+ );
+ Assert.equal(
+ AboutNewTab.newTabURL,
+ "about:newtab",
+ "Newtab URL should be the default"
+ );
+
+ // test override to a chrome URL
+ notificationPromise = nextChangeNotificationPromise(DOWNLOADS_URL);
+ AboutNewTab.newTabURL = DOWNLOADS_URL;
+ await notificationPromise;
+ Assert.ok(AboutNewTab.newTabURLOverridden, "Newtab URL should be overridden");
+ Assert.equal(
+ AboutNewTab.newTabURL,
+ DOWNLOADS_URL,
+ "Newtab URL should be the custom URL"
+ );
+
+ cleanup();
+});
+
+addTestsWithPrivilegedContentProcessPref(
+ async function test_override_activity_stream_enabled() {
+ Assert.equal(
+ aboutNewTabService.defaultURL,
+ ACTIVITY_STREAM_URL,
+ "Newtab URL should be the default activity stream URL"
+ );
+ Assert.ok(
+ !AboutNewTab.newTabURLOverridden,
+ "Newtab URL should not be overridden"
+ );
+ Assert.ok(
+ AboutNewTab.activityStreamEnabled,
+ "Activity Stream should be enabled"
+ );
+
+ // change to a chrome URL while activity stream is enabled
+ let notificationPromise = nextChangeNotificationPromise(DOWNLOADS_URL);
+ AboutNewTab.newTabURL = DOWNLOADS_URL;
+ await notificationPromise;
+ Assert.equal(
+ AboutNewTab.newTabURL,
+ DOWNLOADS_URL,
+ "Newtab URL set to chrome url"
+ );
+ Assert.equal(
+ aboutNewTabService.defaultURL,
+ ACTIVITY_STREAM_URL,
+ "Newtab URL defaultURL still set to the default activity stream URL"
+ );
+ Assert.ok(
+ AboutNewTab.newTabURLOverridden,
+ "Newtab URL should be overridden"
+ );
+ Assert.ok(
+ !AboutNewTab.activityStreamEnabled,
+ "Activity Stream should not be enabled"
+ );
+
+ cleanup();
+ }
+);
+
+addTestsWithPrivilegedContentProcessPref(async function test_default_url() {
+ Assert.equal(
+ aboutNewTabService.defaultURL,
+ ACTIVITY_STREAM_URL,
+ "Newtab defaultURL initially set to AS url"
+ );
+
+ // Only debug variants aren't available on release/beta
+ if (!IS_RELEASE_OR_BETA) {
+ await setBoolPrefAndWaitForChange(
+ ACTIVITY_STREAM_DEBUG_PREF,
+ true,
+ "A notification occurs after changing the debug pref to true"
+ );
+ Assert.equal(
+ AboutNewTab.activityStreamDebug,
+ true,
+ "the .activityStreamDebug property is set to true"
+ );
+ Assert.equal(
+ aboutNewTabService.defaultURL,
+ ACTIVITY_STREAM_DEBUG_URL,
+ "Newtab defaultURL set to debug AS url after the pref has been changed"
+ );
+ await setBoolPrefAndWaitForChange(
+ ACTIVITY_STREAM_DEBUG_PREF,
+ false,
+ "A notification occurs after changing the debug pref to false"
+ );
+ } else {
+ Services.prefs.setBoolPref(ACTIVITY_STREAM_DEBUG_PREF, true);
+
+ Assert.equal(
+ AboutNewTab.activityStreamDebug,
+ false,
+ "the .activityStreamDebug property is remains false"
+ );
+ }
+
+ Assert.equal(
+ aboutNewTabService.defaultURL,
+ ACTIVITY_STREAM_URL,
+ "Newtab defaultURL set to un-prerendered AS if prerender is false and debug is false"
+ );
+
+ cleanup();
+});
+
+addTestsWithPrivilegedContentProcessPref(async function test_welcome_url() {
+ // Disable about:welcome to load newtab
+ Services.prefs.setBoolPref(SIMPLIFIED_WELCOME_ENABLED_PREF, false);
+ Assert.equal(
+ aboutNewTabService.welcomeURL,
+ ACTIVITY_STREAM_URL,
+ "Newtab welcomeURL set to un-prerendered AS when debug disabled."
+ );
+ Assert.equal(
+ aboutNewTabService.welcomeURL,
+ aboutNewTabService.defaultURL,
+ "Newtab welcomeURL is equal to defaultURL when prerendering disabled and debug disabled."
+ );
+
+ // Only debug variants aren't available on release/beta
+ if (!IS_RELEASE_OR_BETA) {
+ await setBoolPrefAndWaitForChange(
+ ACTIVITY_STREAM_DEBUG_PREF,
+ true,
+ "A notification occurs after changing the debug pref to true."
+ );
+ Assert.equal(
+ aboutNewTabService.welcomeURL,
+ ACTIVITY_STREAM_DEBUG_URL,
+ "Newtab welcomeURL set to un-prerendered debug AS when debug enabled"
+ );
+ }
+
+ cleanup();
+});
+
+/**
+ * Tests response to updates to prefs
+ */
+addTestsWithPrivilegedContentProcessPref(async function test_updates() {
+ // Simulates a "cold-boot" situation, with some pref already set before testing a series
+ // of changes.
+ AboutNewTab.resetNewTabURL(); // need to set manually because pref notifs are off
+ let notificationPromise;
+
+ // test update fires on override and reset
+ let testURL = "https://example.com/";
+ notificationPromise = nextChangeNotificationPromise(
+ testURL,
+ "a notification occurs on override"
+ );
+ AboutNewTab.newTabURL = testURL;
+ await notificationPromise;
+
+ // from overridden to default
+ notificationPromise = nextChangeNotificationPromise(
+ "about:newtab",
+ "a notification occurs on reset"
+ );
+ AboutNewTab.resetNewTabURL();
+ Assert.ok(
+ AboutNewTab.activityStreamEnabled,
+ "Activity Stream should be enabled"
+ );
+ Assert.equal(
+ aboutNewTabService.defaultURL,
+ ACTIVITY_STREAM_URL,
+ "Default URL should be the activity stream page"
+ );
+ await notificationPromise;
+
+ // reset twice, only one notification for default URL
+ notificationPromise = nextChangeNotificationPromise(
+ "about:newtab",
+ "reset occurs"
+ );
+ AboutNewTab.resetNewTabURL();
+ await notificationPromise;
+
+ cleanup();
+});
diff --git a/browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js b/browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js
new file mode 100644
index 0000000000..2b2c55b47b
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AboutWelcomeDefaults } = ChromeUtils.import(
+ "resource://activity-stream/aboutwelcome/lib/AboutWelcomeDefaults.jsm"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { AttributionCode } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+const { AddonRepository } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonRepository.sys.mjs"
+);
+
+const TEST_ATTRIBUTION_DATA = {
+ source: "addons.mozilla.org",
+ medium: "referral",
+ campaign: "non-fx-button",
+ content: "rta:iridium%40particlecore.github.io",
+};
+
+add_task(async function test_handleAddonInfoNotFound() {
+ let sandbox = sinon.createSandbox();
+ const stub = sandbox.stub(AttributionCode, "getAttrDataAsync").resolves(null);
+ let result = await AboutWelcomeDefaults.getAttributionContent();
+ equal(stub.callCount, 1, "Call was made");
+ equal(result, null, "No data is returned");
+
+ sandbox.restore();
+});
+
+add_task(async function test_UAAttribution() {
+ let sandbox = sinon.createSandbox();
+ const stub = sandbox
+ .stub(AttributionCode, "getAttrDataAsync")
+ .resolves({ ua: "test" });
+ let result = await AboutWelcomeDefaults.getAttributionContent();
+ equal(stub.callCount, 1, "Call was made");
+ equal(result.template, undefined, "Template was not returned");
+ equal(result.ua, "test", "UA was returned");
+
+ sandbox.restore();
+});
+
+add_task(async function test_formatAttributionData() {
+ let sandbox = sinon.createSandbox();
+ const TEST_ADDON_INFO = {
+ sourceURI: { scheme: "https", spec: "https://test.xpi" },
+ name: "Test Add-on",
+ icons: { 64: "http://test.svg" },
+ };
+ sandbox
+ .stub(AttributionCode, "getAttrDataAsync")
+ .resolves(TEST_ATTRIBUTION_DATA);
+ sandbox.stub(AddonRepository, "getAddonsByIDs").resolves([TEST_ADDON_INFO]);
+ let result = await AboutWelcomeDefaults.getAttributionContent(
+ TEST_ATTRIBUTION_DATA
+ );
+ equal(AddonRepository.getAddonsByIDs.callCount, 1, "Retrieve addon content");
+ equal(result.template, "return_to_amo", "RTAMO template returned");
+ equal(result.name, TEST_ADDON_INFO.name, "AddonInfo returned");
+
+ sandbox.restore();
+});
diff --git a/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js b/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js
new file mode 100644
index 0000000000..5ecc20f804
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AboutWelcomeTelemetry } = ChromeUtils.import(
+ "resource://activity-stream/aboutwelcome/lib/AboutWelcomeTelemetry.jsm"
+);
+const { AttributionCode } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const TELEMETRY_PREF = "browser.newtabpage.activity-stream.telemetry";
+
+add_setup(function setup() {
+ do_get_profile();
+ Services.fog.initializeFOG();
+});
+
+add_task(function test_enabled() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(TELEMETRY_PREF);
+ });
+ Services.prefs.setBoolPref(TELEMETRY_PREF, true);
+
+ const AWTelemetry = new AboutWelcomeTelemetry();
+
+ equal(AWTelemetry.telemetryEnabled, true, "Telemetry should be on");
+
+ Services.prefs.setBoolPref(TELEMETRY_PREF, false);
+
+ equal(AWTelemetry.telemetryEnabled, false, "Telemetry should be off");
+});
+
+add_task(async function test_pingPayload() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(TELEMETRY_PREF);
+ });
+ Services.prefs.setBoolPref(TELEMETRY_PREF, true);
+ const AWTelemetry = new AboutWelcomeTelemetry();
+ const stub = sinon.stub(
+ AWTelemetry.pingCentre,
+ "sendStructuredIngestionPing"
+ );
+ sinon.stub(AWTelemetry, "_createPing").resolves({ event: "MOCHITEST" });
+
+ let pingSubmitted = false;
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+ Assert.equal(Glean.messagingSystem.event.testGetValue(), "MOCHITEST");
+ });
+ await AWTelemetry.sendTelemetry();
+
+ equal(stub.callCount, 1, "Call was made");
+ // check the endpoint
+ ok(
+ stub.firstCall.args[1].includes("/messaging-system/onboarding"),
+ "Endpoint is correct"
+ );
+
+ ok(pingSubmitted, "Glean ping was submitted");
+});
+
+add_task(function test_mayAttachAttribution() {
+ const sandbox = sinon.createSandbox();
+ const AWTelemetry = new AboutWelcomeTelemetry();
+
+ sandbox.stub(AttributionCode, "getCachedAttributionData").returns(null);
+
+ let ping = AWTelemetry._maybeAttachAttribution({});
+
+ equal(ping.attribution, undefined, "Should not set attribution if it's null");
+
+ sandbox.restore();
+ sandbox.stub(AttributionCode, "getCachedAttributionData").returns({});
+ ping = AWTelemetry._maybeAttachAttribution({});
+
+ equal(
+ ping.attribution,
+ undefined,
+ "Should not set attribution if it's empty"
+ );
+
+ const attr = {
+ source: "google.com",
+ medium: "referral",
+ campaign: "Firefox-Brand-US-Chrome",
+ content: "(not set)",
+ experiment: "(not set)",
+ variation: "(not set)",
+ ua: "chrome",
+ };
+ sandbox.restore();
+ sandbox.stub(AttributionCode, "getCachedAttributionData").returns(attr);
+ ping = AWTelemetry._maybeAttachAttribution({});
+
+ equal(ping.attribution, attr, "Should set attribution if it presents");
+});
diff --git a/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry_glean.js b/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry_glean.js
new file mode 100644
index 0000000000..a49a6f9382
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry_glean.js
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AboutWelcomeTelemetry } = ChromeUtils.import(
+ "resource://activity-stream/aboutwelcome/lib/AboutWelcomeTelemetry.jsm"
+);
+const TELEMETRY_PREF = "browser.newtabpage.activity-stream.telemetry";
+
+add_setup(function setup() {
+ do_get_profile();
+ Services.fog.initializeFOG();
+});
+
+// We recognize two kinds of unexpected data that might reach
+// `submitGleanPingForPing`: unknown keys, and keys with unexpectedly-complex
+// data (ie, non-scalar).
+// We report the keys in special metrics to aid in system health monitoring.
+add_task(function test_weird_data() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(TELEMETRY_PREF);
+ });
+ Services.prefs.setBoolPref(TELEMETRY_PREF, true);
+
+ const AWTelemetry = new AboutWelcomeTelemetry();
+
+ const unknownKey = "some_unknown_key";
+ const camelUnknownKey = AWTelemetry._snakeToCamelCase(unknownKey);
+
+ let pingSubmitted = false;
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+ Assert.equal(
+ Glean.messagingSystem.unknownKeys[camelUnknownKey].testGetValue(),
+ 1,
+ "caught the unknown key"
+ );
+ // TODO(bug 1600008): Also check the for-testing overall count.
+ Assert.equal(Glean.messagingSystem.unknownKeyCount.testGetValue(), 1);
+ });
+ AWTelemetry.submitGleanPingForPing({
+ [unknownKey]: "value doesn't matter",
+ });
+
+ Assert.ok(pingSubmitted, "Ping with unknown keys was submitted");
+
+ const invalidNestedDataKey = "event";
+ pingSubmitted = false;
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+ Assert.equal(
+ Glean.messagingSystem.invalidNestedData[
+ invalidNestedDataKey
+ ].testGetValue("messaging-system"),
+ 1,
+ "caught the invalid nested data"
+ );
+ });
+ AWTelemetry.submitGleanPingForPing({
+ [invalidNestedDataKey]: { this_should: "not be", complex: "data" },
+ });
+
+ Assert.ok(pingSubmitted, "Ping with invalid nested data submitted");
+});
+
+// `event_context` is weird. It's an object, but it might have been stringified
+// before being provided for recording.
+add_task(function test_event_context() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(TELEMETRY_PREF);
+ });
+ Services.prefs.setBoolPref(TELEMETRY_PREF, true);
+
+ const AWTelemetry = new AboutWelcomeTelemetry();
+
+ const eventContext = {
+ reason: "reason",
+ page: "page",
+ source: "source",
+ something_else: "not specifically handled",
+ };
+ const stringifiedEC = JSON.stringify(eventContext);
+
+ let pingSubmitted = false;
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+ Assert.equal(
+ Glean.messagingSystem.eventReason.testGetValue(),
+ eventContext.reason,
+ "event_context.reason also in own metric."
+ );
+ Assert.equal(
+ Glean.messagingSystem.eventPage.testGetValue(),
+ eventContext.page,
+ "event_context.page also in own metric."
+ );
+ Assert.equal(
+ Glean.messagingSystem.eventSource.testGetValue(),
+ eventContext.source,
+ "event_context.source also in own metric."
+ );
+ Assert.equal(
+ Glean.messagingSystem.eventContext.testGetValue(),
+ stringifiedEC,
+ "whole event_context added as text."
+ );
+ });
+ AWTelemetry.submitGleanPingForPing({
+ event_context: eventContext,
+ });
+ Assert.ok(pingSubmitted, "Ping with object event_context submitted");
+
+ pingSubmitted = false;
+ GleanPings.messagingSystem.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+ Assert.equal(
+ Glean.messagingSystem.eventReason.testGetValue(),
+ eventContext.reason,
+ "event_context.reason also in own metric."
+ );
+ Assert.equal(
+ Glean.messagingSystem.eventPage.testGetValue(),
+ eventContext.page,
+ "event_context.page also in own metric."
+ );
+ Assert.equal(
+ Glean.messagingSystem.eventSource.testGetValue(),
+ eventContext.source,
+ "event_context.source also in own metric."
+ );
+ Assert.equal(
+ Glean.messagingSystem.eventContext.testGetValue(),
+ stringifiedEC,
+ "whole event_context added as text."
+ );
+ });
+ AWTelemetry.submitGleanPingForPing({
+ event_context: stringifiedEC,
+ });
+ Assert.ok(pingSubmitted, "Ping with string event_context submitted");
+});
diff --git a/browser/components/newtab/test/xpcshell/test_CFRMessageProvider.js b/browser/components/newtab/test/xpcshell/test_CFRMessageProvider.js
new file mode 100644
index 0000000000..acdd4a2e2b
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_CFRMessageProvider.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/CFRMessageProvider.sys.mjs"
+);
+
+add_task(async function test_cfrMessages() {
+ const { experimentValidator, messageValidators } = await makeValidators();
+
+ const messages = await CFRMessageProvider.getMessages();
+ for (const message of messages) {
+ const validator = messageValidators[message.template];
+ Assert.ok(
+ typeof validator !== "undefined",
+ typeof validator !== "undefined"
+ ? `Schema validator found for ${message.template}.`
+ : `No schema validator found for template ${message.template}. Please update this test to add one.`
+ );
+
+ assertValidates(
+ validator,
+ message,
+ `Message ${message.id} validates as template ${message.template}`
+ );
+ assertValidates(
+ experimentValidator,
+ message,
+ `Message ${message.id} validates as MessagingExperiment`
+ );
+ }
+});
diff --git a/browser/components/newtab/test/xpcshell/test_InflightAssetsMessageProvider.js b/browser/components/newtab/test/xpcshell/test_InflightAssetsMessageProvider.js
new file mode 100644
index 0000000000..ad1bd1dbff
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_InflightAssetsMessageProvider.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { InflightAssetsMessageProvider } = ChromeUtils.import(
+ "resource://testing-common/InflightAssetsMessageProvider.jsm"
+);
+
+const MESSAGE_VALIDATORS = {};
+let EXPERIMENT_VALIDATOR;
+
+add_setup(async function setup() {
+ const validators = await makeValidators();
+
+ EXPERIMENT_VALIDATOR = validators.experimentValidator;
+ Object.assign(MESSAGE_VALIDATORS, validators.messageValidators);
+});
+
+add_task(function test_InflightAssetsMessageProvider() {
+ const messages = InflightAssetsMessageProvider.getMessages();
+
+ for (const message of messages) {
+ const validator = MESSAGE_VALIDATORS[message.template];
+ Assert.ok(
+ typeof validator !== "undefined",
+ typeof validator !== "undefined"
+ ? `Schema validator found for ${message.template}`
+ : `No schema validator found for template ${message.template}. Please update this test to add one.`
+ );
+
+ assertValidates(
+ validator,
+ message,
+ `Message ${message.id} validates as ${message.template} template`
+ );
+ assertValidates(
+ EXPERIMENT_VALIDATOR,
+ message,
+ `Message ${message.id} validates as a MessagingExperiment`
+ );
+ }
+});
diff --git a/browser/components/newtab/test/xpcshell/test_OnboardingMessageProvider.js b/browser/components/newtab/test/xpcshell/test_OnboardingMessageProvider.js
new file mode 100644
index 0000000000..0ad7a6cbee
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_OnboardingMessageProvider.js
@@ -0,0 +1,229 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { OnboardingMessageProvider } = ChromeUtils.import(
+ "resource://activity-stream/lib/OnboardingMessageProvider.jsm"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+function getOnboardingScreenById(screens, screenId) {
+ return screens.find(screen => {
+ return screen?.id === screenId;
+ });
+}
+
+add_task(
+ async function test_OnboardingMessageProvider_getUpgradeMessage_no_pin() {
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(true);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+ // If Firefox is not pinned, the screen should have "pin" content
+ equal(
+ message.content.screens[0].id,
+ "UPGRADE_PIN_FIREFOX",
+ "Screen has pin screen id"
+ );
+ equal(
+ message.content.screens[0].content.primary_button.action.type,
+ "PIN_FIREFOX_TO_TASKBAR",
+ "Primary button has pin action type"
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function test_OnboardingMessageProvider_getUpgradeMessage_pin_no_default() {
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false);
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(true);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+ // If Firefox is pinned, but not the default, the screen should have "make default" content
+ equal(
+ message.content.screens[0].id,
+ "UPGRADE_ONLY_DEFAULT",
+ "Screen has make default screen id"
+ );
+ equal(
+ message.content.screens[0].content.primary_button.action.type,
+ "SET_DEFAULT_BROWSER",
+ "Primary button has make default action"
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function test_OnboardingMessageProvider_getUpgradeMessage_pin_and_default() {
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false);
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(false);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+ // If Firefox is pinned and the default, the screen should have "get started" content
+ equal(
+ message.content.screens[0].id,
+ "UPGRADE_GET_STARTED",
+ "Screen has get started screen id"
+ );
+ ok(
+ !message.content.screens[0].content.primary_button.action.type,
+ "Primary button has no action type"
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(async function test_OnboardingMessageProvider_getNoImport_default() {
+ let sandbox = sinon.createSandbox();
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(false);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+
+ // No import screen is shown when user has Firefox both pinned and default
+ Assert.notEqual(
+ message.content.screens[1]?.id,
+ "UPGRADE_IMPORT_SETTINGS",
+ "Screen has no import screen id"
+ );
+ sandbox.restore();
+});
+
+add_task(async function test_OnboardingMessageProvider_getImport_nodefault() {
+ Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser");
+ });
+
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedDefault").resolves(true);
+ sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+
+ // Import screen is shown when user doesn't have Firefox pinned and default
+ Assert.equal(
+ message.content.screens[1]?.id,
+ "UPGRADE_IMPORT_SETTINGS",
+ "Screen has import screen id"
+ );
+ sandbox.restore();
+});
+
+add_task(
+ async function test_OnboardingMessageProvider_getPinPrivateWindow_noPrivatePin() {
+ Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser");
+ });
+ let sandbox = sinon.createSandbox();
+ // User needs default to ensure Pin Private window shows as third screen after import
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(true);
+
+ let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin");
+ pinStub.resolves(false);
+ pinStub.withArgs(true).resolves(true);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+
+ // Pin Private screen is shown when user doesn't have Firefox private pinned but has Firefox pinned
+ Assert.ok(
+ getOnboardingScreenById(
+ message.content.screens,
+ "UPGRADE_PIN_PRIVATE_WINDOW"
+ )
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(
+ async function test_OnboardingMessageProvider_getNoPinPrivateWindow_noPin() {
+ Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser");
+ });
+ let sandbox = sinon.createSandbox();
+ // User needs default to ensure Pin Private window shows as third screen after import
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(true);
+
+ let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin");
+ pinStub.resolves(true);
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+
+ // Pin Private screen is not shown when user doesn't have Firefox pinned
+ Assert.ok(
+ !getOnboardingScreenById(
+ message.content.screens,
+ "UPGRADE_PIN_PRIVATE_WINDOW"
+ )
+ );
+ sandbox.restore();
+ }
+);
+
+add_task(async function test_schemaValidation() {
+ const { experimentValidator, messageValidators } = await makeValidators();
+
+ const messages = await OnboardingMessageProvider.getMessages();
+ for (const message of messages) {
+ const validator = messageValidators[message.template];
+
+ Assert.ok(
+ typeof validator !== "undefined",
+ typeof validator !== "undefined"
+ ? `Schema validator found for ${message.template}.`
+ : `No schema validator found for template ${message.template}. Please update this test to add one.`
+ );
+ assertValidates(
+ validator,
+ message,
+ `Message ${message.id} validates as template ${message.template}`
+ );
+ assertValidates(
+ experimentValidator,
+ message,
+ `Message ${message.id} validates as MessagingExperiment`
+ );
+ }
+});
+
+add_task(
+ async function test_OnboardingMessageProvider_getPinPrivateWindow_pinPBMPrefDisabled() {
+ Services.prefs.setBoolPref(
+ "browser.startup.upgradeDialog.pinPBM.disabled",
+ true
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(
+ "browser.startup.upgradeDialog.pinPBM.disabled"
+ );
+ });
+ let sandbox = sinon.createSandbox();
+ // User needs default to ensure Pin Private window shows as third screen after import
+ sandbox
+ .stub(OnboardingMessageProvider, "_doesAppNeedDefault")
+ .resolves(true);
+
+ let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin");
+ pinStub.resolves(true);
+
+ const message = await OnboardingMessageProvider.getUpgradeMessage();
+ // Pin Private screen is not shown when pref is turned on
+ Assert.ok(
+ !getOnboardingScreenById(
+ message.content.screens,
+ "UPGRADE_PIN_PRIVATE_WINDOW"
+ )
+ );
+ sandbox.restore();
+ }
+);
diff --git a/browser/components/newtab/test/xpcshell/test_PanelTestProvider.js b/browser/components/newtab/test/xpcshell/test_PanelTestProvider.js
new file mode 100644
index 0000000000..d5c5c19f0c
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_PanelTestProvider.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/PanelTestProvider.sys.mjs"
+);
+
+const MESSAGE_VALIDATORS = {};
+let EXPERIMENT_VALIDATOR;
+
+add_setup(async function setup() {
+ const validators = await makeValidators();
+
+ EXPERIMENT_VALIDATOR = validators.experimentValidator;
+ Object.assign(MESSAGE_VALIDATORS, validators.messageValidators);
+});
+
+add_task(async function test_PanelTestProvider() {
+ const messages = await PanelTestProvider.getMessages();
+
+ const EXPECTED_MESSAGE_COUNTS = {
+ cfr_doorhanger: 1,
+ milestone_message: 0,
+ update_action: 1,
+ whatsnew_panel_message: 7,
+ spotlight: 2,
+ pb_newtab: 2,
+ toast_notification: 2,
+ };
+
+ const EXPECTED_TOTAL_MESSAGE_COUNT = Object.values(
+ EXPECTED_MESSAGE_COUNTS
+ ).reduce((a, b) => a + b, 0);
+
+ Assert.strictEqual(
+ messages.length,
+ EXPECTED_TOTAL_MESSAGE_COUNT,
+ "PanelTestProvider should have the correct number of messages"
+ );
+
+ const messageCounts = Object.assign(
+ {},
+ ...Object.keys(EXPECTED_MESSAGE_COUNTS).map(key => ({ [key]: 0 }))
+ );
+
+ for (const message of messages) {
+ const validator = MESSAGE_VALIDATORS[message.template];
+ Assert.ok(
+ typeof validator !== "undefined",
+ typeof validator !== "undefined"
+ ? `Schema validator found for ${message.template}`
+ : `No schema validator found for template ${message.template}. Please update this test to add one.`
+ );
+ assertValidates(
+ validator,
+ message,
+ `Message ${message.id} validates as ${message.template} template`
+ );
+ assertValidates(
+ EXPERIMENT_VALIDATOR,
+ message,
+ `Message ${message.id} validates as MessagingExperiment`
+ );
+
+ messageCounts[message.template]++;
+ }
+
+ for (const [template, count] of Object.entries(messageCounts)) {
+ Assert.equal(
+ count,
+ EXPECTED_MESSAGE_COUNTS[template],
+ `Expected ${EXPECTED_MESSAGE_COUNTS[template]} ${template} messages`
+ );
+ }
+});
+
+add_task(async function test_emptyMessage() {
+ info(
+ "Testing blank FxMS messages validate with the Messaging Experiment schema"
+ );
+
+ assertValidates(EXPERIMENT_VALIDATOR, {}, "Empty message should validate");
+});
diff --git a/browser/components/newtab/test/xpcshell/test_reach_experiments.js b/browser/components/newtab/test/xpcshell/test_reach_experiments.js
new file mode 100644
index 0000000000..240bda3594
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_reach_experiments.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { ObjectUtils } = ChromeUtils.import(
+ "resource://gre/modules/ObjectUtils.jsm"
+);
+
+const MESSAGES = [
+ {
+ trigger: { id: "defaultBrowserCheck" },
+ targeting:
+ "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5",
+ },
+ {
+ groups: ["eco"],
+ trigger: {
+ id: "defaultBrowserCheck",
+ },
+ targeting:
+ "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5",
+ },
+];
+
+let EXPERIMENT_VALIDATOR;
+
+add_setup(async function setup() {
+ EXPERIMENT_VALIDATOR = await schemaValidatorFor(
+ "resource://activity-stream/schemas/MessagingExperiment.schema.json"
+ );
+});
+
+add_task(function test_reach_experiments_validation() {
+ for (const [index, message] of MESSAGES.entries()) {
+ assertValidates(
+ EXPERIMENT_VALIDATOR,
+ message,
+ `Message ${index} validates as a MessagingExperiment`
+ );
+ }
+});
+
+function depError(has, missing) {
+ return {
+ instanceLocation: "#",
+ keyword: "dependentRequired",
+ keywordLocation: "#/oneOf/1/allOf/0/$ref/dependantRequired",
+ error: `Instance has "${has}" but does not have "${missing}".`,
+ };
+}
+
+function assertContains(haystack, needle) {
+ Assert.ok(
+ haystack.find(item => ObjectUtils.deepEqual(item, needle)) !== null
+ );
+}
+
+add_task(function test_reach_experiment_dependentRequired() {
+ info(
+ "Testing that if id is present then content and template are not required"
+ );
+
+ {
+ const message = {
+ ...MESSAGES[0],
+ id: "message-id",
+ };
+
+ const result = EXPERIMENT_VALIDATOR.validate(message);
+ Assert.ok(result.valid, "message should validate");
+ }
+
+ info("Testing that if content is present then id and template are required");
+ {
+ const message = {
+ ...MESSAGES[0],
+ content: {},
+ };
+
+ const result = EXPERIMENT_VALIDATOR.validate(message);
+ Assert.ok(!result.valid, "message should not validate");
+ assertContains(result.errors, depError("content", "id"));
+ assertContains(result.errors, depError("content", "template"));
+ }
+
+ info("Testing that if template is present then id and content are required");
+ {
+ const message = {
+ ...MESSAGES[0],
+ template: "cfr",
+ };
+
+ const result = EXPERIMENT_VALIDATOR.validate(message);
+ Assert.ok(!result.valid, "message should not validate");
+ assertContains(result.errors, depError("template", "content"));
+ assertContains(result.errors, depError("template", "id"));
+ }
+});
diff --git a/browser/components/newtab/test/xpcshell/test_remoteExperiments.js b/browser/components/newtab/test/xpcshell/test_remoteExperiments.js
new file mode 100644
index 0000000000..6964d34023
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/test_remoteExperiments.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource://activity-stream/lib/CFRMessageProvider.sys.mjs"
+);
+
+add_task(async function test_multiMessageTreatment() {
+ const { experimentValidator } = await makeValidators();
+ // Use the entire list of messages as if it was a single treatment branch's
+ // feature value.
+ let messages = await CFRMessageProvider.getMessages();
+ let featureValue = { template: "multi", messages };
+ assertValidates(
+ experimentValidator,
+ featureValue,
+ `Multi-message treatment validates as MessagingExperiment`
+ );
+ for (const message of messages) {
+ assertValidates(
+ experimentValidator,
+ message,
+ `Message ${message.id} validates as MessagingExperiment`
+ );
+ }
+
+ // Add an invalid message to the list and make sure it fails validation.
+ messages.push({
+ id: "INVALID_MESSAGE",
+ template: "cfr_doorhanger",
+ });
+ const result = experimentValidator.validate(featureValue);
+ Assert.ok(
+ !(result.valid && result.errors.length === 0),
+ "Multi-message treatment with invalid message fails validation"
+ );
+});
diff --git a/browser/components/newtab/test/xpcshell/topstories.json b/browser/components/newtab/test/xpcshell/topstories.json
new file mode 100644
index 0000000000..7d65fcb0e1
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/topstories.json
@@ -0,0 +1,53 @@
+{
+ "status": 1,
+ "settings": {
+ "spocsPerNewTabs": 0.5,
+ "domainAffinityParameterSets": {
+ "default": {
+ "recencyFactor": 0.5,
+ "frequencyFactor": 0.5,
+ "combinedDomainFactor": 0.5,
+ "perfectFrequencyVisits": 10,
+ "perfectCombinedDomainScore": 2,
+ "multiDomainBoost": 0,
+ "itemScoreFactor": 1
+ },
+ "fully-personalized": {
+ "recencyFactor": 0.5,
+ "frequencyFactor": 0.5,
+ "combinedDomainFactor": 0.5,
+ "perfectFrequencyVisits": 10,
+ "perfectCombinedDomainScore": 2,
+ "itemScoreFactor": 0.01,
+ "multiDomainBoost": 0
+ }
+ },
+ "timeSegments": [
+ { "id": "week", "startTime": 604800, "endTime": 0, "weightPosition": 1 },
+ {
+ "id": "month",
+ "startTime": 2592000,
+ "endTime": 604800,
+ "weightPosition": 0.5
+ }
+ ],
+ "recsExpireTime": 5400,
+ "version": "2c2aa06dac65ddb647d8902aaa60263c8e119ff2"
+ },
+ "spocs": [],
+ "recommendations": [
+ {
+ "id": 53093,
+ "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAA/UlEQVR4nO3RMQ0AMAzAsPIn3d5DsBw2gkiZJWV+B/AyJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQmAP4K6zWNUjE4wAAAABJRU5ErkJggg==",
+ "domain": "bbc.com",
+ "title": "Why vegan junk food may be even worse for your health",
+ "excerpt": "While we might switch to a plant-based diet with the best intentions, the unseen risks of vegan fast foods might not show up for years.",
+ "image_src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAA/UlEQVR4nO3RMQ0AMAzAsPIn3d5DsBw2gkiZJWV+B/AyJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQmAP4K6zWNUjE4wAAAABJRU5ErkJggg==",
+ "published_timestamp": "1580277600",
+ "engagement": "",
+ "parameter_set": "default",
+ "domain_affinities": {},
+ "item_score": 1
+ }
+ ]
+}
diff --git a/browser/components/newtab/test/xpcshell/xpcshell.ini b/browser/components/newtab/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..807214219e
--- /dev/null
+++ b/browser/components/newtab/test/xpcshell/xpcshell.ini
@@ -0,0 +1,32 @@
+[DEFAULT]
+head = head.js
+firefox-appdir = browser
+skip-if = toolkit == 'android' # bug 1730213
+prefs =
+ browser.startup.homepage.abouthome_cache.enabled=true
+ browser.startup.homepage.abouthome_cache.testing=true
+
+[test_AboutHomeStartupCacheChild.js]
+[test_AboutHomeStartupCacheWorker.js]
+support-files =
+ ds_layout.json
+ topstories.json
+skip-if =
+ socketprocess_networking # Bug 1759035
+
+[test_AboutNewTab.js]
+[test_AboutWelcomeAttribution.js]
+[test_ASRouterTargeting_attribution.js]
+skip-if =
+ toolkit != "cocoa" # osx specific tests
+ os == "mac" && bits == 64 # See bug 1784121
+[test_ASRouter_getTargetingParameters.js]
+[test_ASRouterTargeting_snapshot.js]
+[test_AboutWelcomeTelemetry.js]
+[test_CFRMessageProvider.js]
+[test_InflightAssetsMessageProvider.js]
+[test_OnboardingMessageProvider.js]
+[test_PanelTestProvider.js]
+[test_reach_experiments.js]
+[test_remoteExperiments.js]
+[test_AboutWelcomeTelemetry_glean.js]