diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /browser/components/newtab/test/browser | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/test/browser')
84 files changed, 15893 insertions, 0 deletions
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 + } + ] +} |