diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/asrouter/tests | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/asrouter/tests')
52 files changed, 18922 insertions, 0 deletions
diff --git a/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs b/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs new file mode 100644 index 0000000000..e92b210c12 --- /dev/null +++ b/browser/components/asrouter/tests/InflightAssetsMessageProvider.sys.mjs @@ -0,0 +1,340 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// This file is generated by: +// https://github.com/mozilla/messaging-system-inflight-assets/tree/master/scripts/export-all.py + +export const InflightAssetsMessageProvider = { + getMessages() { + return [ + { + id: "MILESTONE_MESSAGE", + groups: ["cfr"], + content: { + anchor_id: "tracking-protection-icon-box", + bucket_id: "CFR_MILESTONE_MESSAGE", + buttons: { + primary: { + action: { + type: "OPEN_PROTECTION_REPORT", + }, + event: "PROTECTION", + label: { + string_id: "cfr-doorhanger-milestone-ok-button", + }, + }, + secondary: [ + { + label: { + string_id: "cfr-doorhanger-milestone-close-button", + }, + action: { + type: "CANCEL", + }, + event: "DISMISS", + }, + ], + }, + category: "cfrFeatures", + heading_text: { + string_id: "cfr-doorhanger-milestone-heading", + }, + layout: "short_message", + notification_text: "", + skip_address_bar_notifier: true, + text: "", + }, + frequency: { + lifetime: 7, + }, + targeting: + "pageLoad >= 4 && firefoxVersion < 87 && userPrefs.cfrFeatures", + template: "milestone_message", + trigger: { + id: "contentBlocking", + params: ["ContentBlockingMilestone"], + }, + }, + { + id: "MILESTONE_MESSAGE_87", + groups: ["cfr"], + content: { + anchor_id: "tracking-protection-icon-box", + bucket_id: "CFR_MILESTONE_MESSAGE", + buttons: { + primary: { + action: { + type: "OPEN_PROTECTION_REPORT", + }, + event: "PROTECTION", + label: { + string_id: "cfr-doorhanger-milestone-ok-button", + }, + }, + secondary: [ + { + label: { + string_id: "cfr-doorhanger-milestone-close-button", + }, + action: { + type: "CANCEL", + }, + event: "DISMISS", + }, + ], + }, + category: "cfrFeatures", + heading_text: { + string_id: "cfr-doorhanger-milestone-heading2", + }, + layout: "short_message", + notification_text: "", + skip_address_bar_notifier: true, + text: "", + }, + frequency: { + lifetime: 7, + }, + targeting: + "pageLoad >= 4 && firefoxVersion >= 87 && userPrefs.cfrFeatures", + template: "milestone_message", + trigger: { + id: "contentBlocking", + params: ["ContentBlockingMilestone"], + }, + }, + { + id: "DOH_ROLLOUT_CONFIRMATION_89", + groups: ["cfr"], + targeting: + "profileAgeCreated < 1572480000000 && ( 'doh-rollout.enabled'|preferenceValue || 'doh-rollout.self-enabled'|preferenceValue || 'doh-rollout.ru.enabled'|preferenceValue || 'doh-rollout.ua.enabled'|preferenceValue ) && !( 'doh-rollout.disable-heuristics'|preferenceValue || 'doh-rollout.skipHeuristicsCheck'|preferenceValue || 'doh-rollout.doorhanger-decision'|preferenceValue ) && firefoxVersion >= 89", + template: "infobar", + content: { + priority: 3, + type: "global", + text: { + string_id: "cfr-doorhanger-doh-body", + }, + buttons: [ + { + label: { + string_id: "cfr-doorhanger-doh-primary-button-2", + }, + action: { + type: "ACCEPT_DOH", + }, + primary: true, + }, + { + label: { + string_id: "cfr-doorhanger-doh-secondary-button", + }, + action: { + type: "DISABLE_DOH", + }, + }, + { + label: { + string_id: "notification-learnmore-default-label", + }, + supportPage: "dns-over-https", + callback: null, + action: { + type: "CANCEL", + }, + }, + ], + bucket_id: "DOH_ROLLOUT_CONFIRMATION_89", + category: "cfrFeatures", + }, + frequency: { + lifetime: 3, + }, + trigger: { + id: "openURL", + patterns: ["*://*/*"], + }, + }, + { + id: "INFOBAR_DEFAULT_AND_PIN_87", + groups: ["cfr"], + content: { + category: "cfrFeatures", + bucket_id: "INFOBAR_DEFAULT_AND_PIN_87", + text: { + string_id: "default-browser-notification-message", + }, + type: "global", + buttons: [ + { + label: { + string_id: "default-browser-notification-button", + }, + action: { + type: "PIN_AND_DEFAULT", + }, + primary: true, + accessKey: "P", + }, + ], + }, + trigger: { + id: "defaultBrowserCheck", + }, + template: "infobar", + frequency: { + lifetime: 2, + custom: [ + { + period: 3024000000, + cap: 1, + }, + ], + }, + targeting: + "((firefoxVersion >= 87 && firefoxVersion < 89) || (firefoxVersion >= 89 && source == 'startup')) && !isDefaultBrowser && !'browser.shell.checkDefaultBrowser'|preferenceValue && isMajorUpgrade != true && platformName != 'linux' && ((currentDate|date - profileAgeCreated) / 604800000) >= 5 && !activeNotifications && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && ((currentDate|date - profileAgeCreated) / 604800000) < 15", + }, + { + id: "CFR_FULL_VIDEO_SUPPORT_EN", + groups: ["cfr"], + targeting: + "firefoxVersion < 88 && firefoxVersion != 78 && localeLanguageCode in ['en', 'fr', 'de', 'ru', 'zh', 'es', 'it', 'pl']", + template: "cfr_doorhanger", + content: { + skip_address_bar_notifier: true, + persistent_doorhanger: true, + anchor_id: "PanelUI-menu-button", + layout: "icon_and_message", + text: { + string_id: "cfr-doorhanger-video-support-body", + }, + buttons: { + secondary: [ + { + label: { + string_id: "cfr-doorhanger-extension-cancel-button", + }, + action: { + type: "CANCEL", + }, + }, + ], + primary: { + label: { + string_id: "cfr-doorhanger-video-support-primary-button", + }, + action: { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/kb/update-firefox-latest-release", + where: "tabshifted", + }, + }, + }, + }, + bucket_id: "CFR_FULL_VIDEO_SUPPORT_EN", + heading_text: { + string_id: "cfr-doorhanger-video-support-header", + }, + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: "Message from Firefox", + category: "cfrFeatures", + }, + frequency: { + lifetime: 3, + }, + trigger: { + id: "openURL", + patterns: ["https://*/Amazon-Video/*", "https://*/Prime-Video/*"], + params: [ + "www.hulu.com", + "hulu.com", + "www.netflix.com", + "netflix.com", + "www.disneyplus.com", + "disneyplus.com", + "www.hbomax.com", + "hbomax.com", + "www.sho.com", + "sho.com", + "www.directv.com", + "directv.com", + "www.starzplay.com", + "starzplay.com", + "www.sling.com", + "sling.com", + "www.facebook.com", + "facebook.com", + ], + }, + }, + { + id: "WNP_MOMENTS_12", + groups: ["moments-pages"], + content: { + action: { + data: { + expire: 1640908800000, + url: "https://www.mozilla.org/firefox/welcome/12", + }, + id: "moments-wnp", + }, + bucket_id: "WNP_MOMENTS_12", + }, + targeting: + 'localeLanguageCode == "en" && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 1 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue', + template: "update_action", + trigger: { + id: "momentsUpdate", + }, + }, + { + id: "WNP_MOMENTS_13", + groups: ["moments-pages"], + content: { + action: { + data: { + expire: 1640908800000, + url: "https://www.mozilla.org/firefox/welcome/13", + }, + id: "moments-wnp", + }, + bucket_id: "WNP_MOMENTS_13", + }, + targeting: + '(localeLanguageCode in ["en", "de", "fr", "nl", "it", "ms"] || locale == "es-ES") && region in ["DE", "AT", "BE", "CA", "FR", "IE", "IT", "MY", "NL", "NZ", "SG", "CH", "US", "GB", "ES"] && (addonsInfo.addons|keys intersect ["@testpilot-containers"])|length == 0 && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue', + template: "update_action", + trigger: { + id: "momentsUpdate", + }, + }, + { + id: "WNP_MOMENTS_14", + groups: ["moments-pages"], + content: { + action: { + data: { + expire: 1668470400000, + url: "https://www.mozilla.org/firefox/welcome/14", + }, + id: "moments-wnp", + }, + bucket_id: "WNP_MOMENTS_14", + }, + targeting: + 'localeLanguageCode in ["en", "de", "fr"] && region in ["AT", "BE", "CA", "CH", "DE", "ES", "FI", "FR", "GB", "IE", "IT", "MY", "NL", "NZ", "SE", "SG", "US"] && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features\'|preferenceValue && \'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons\'|preferenceValue', + template: "update_action", + trigger: { + id: "momentsUpdate", + }, + }, + ]; + }, +}; diff --git a/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs b/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs new file mode 100644 index 0000000000..5bfbec9557 --- /dev/null +++ b/browser/components/asrouter/tests/NimbusRolloutMessageProvider.sys.mjs @@ -0,0 +1,199 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This file is generated by browser/components/asrouter/bin/import-rollouts.js + * Run the following from the repository root to regenerate it: + * ./mach npm run import-rollouts --prefix=browser/components/asrouter + */ + +export const NimbusRolloutMessageProvider = { + getMessages() { + return [ + { + // Nimbus slug: fox-doodle-set-to-default-early-day-user-de-fr-it-treatment-a-rollout:treatment-a + // Version range: 116+ + // Recipe: https://experimenter.services.mozilla.com/nimbus/fox-doodle-set-to-default-early-day-user-de-fr-it-treatment-a-rollout/summary#treatment-a + id: "fox-doodle-set-to-default-early-day-user-de-fr-it:A", + groups: ["eco"], + content: { + id: "fox-doodle-set-to-default-early-day-user-de-fr-it:A", + screens: [ + { + id: "SET_DEFAULT", + content: { + logo: { + height: "140px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png", + reducedMotionImageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png", + }, + title: { + raw: { + $l10n: { + id: "fox-doodle-trackers-title", + text: "Keep pesky trackers off your tail", + comment: + "This title is displayed together with the picture of a running fox with a long tail. In English, this is a figure of speech meaning 'stop something from following you'. If the localization of this message is challenging, consider using a simplified alternative as a reference for translation: 'Keep unwanted trackers away'.", + }, + }, + fontSize: "22px", + fontWeight: 590, + paddingBlock: "4px 0", + letterSpacing: 0, + paddingInline: "24px", + }, + subtitle: { + raw: { + $l10n: { + id: "fox-doodle-trackers-subtitle", + text: "Say goodbye to annoying ad trackers and settle into a safer, speedy internet experience.", + comment: "", + }, + }, + fontSize: "15px", + lineHeight: "1.4", + marginBlock: "8px 16px", + letterSpacing: 0, + paddingInline: "24px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + raw: { + $l10n: { + id: "fox-doodle-set-default-driving-primary-button-label", + text: "Open my links with Firefox", + comment: "", + }, + }, + marginBlock: "4px 0", + paddingBlock: "0", + paddingInline: "16px", + }, + action: { + type: "SET_DEFAULT_BROWSER", + navigate: true, + }, + }, + secondary_button: { + label: { + raw: { + $l10n: { + id: "fox-doodle-driving-secondary-button-label", + text: "Not now", + comment: "", + }, + }, + marginBlock: "0 -20px", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + priority: 1, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && !isMajorUpgrade && !activeNotifications && (((currentDate|date) - (profileAgeCreated|date)) / 3600000 >= 6) && !isDefaultBrowser", + }, + { + // Nimbus slug: fox-doodle-set-to-default-early-day-user-en-treatment-a-rollout:treatment-a + // Version range: 116+ + // Recipe: https://experimenter.services.mozilla.com/nimbus/fox-doodle-set-to-default-early-day-user-en-treatment-a-rollout/summary#treatment-a + id: "fox-doodle-set-to-default-early-day-user:A", + groups: ["eco"], + content: { + id: "fox-doodle-set-to-default-early-day-user:A", + screens: [ + { + id: "SET_DEFAULT", + content: { + logo: { + height: "140px", + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png", + reducedMotionImageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/05f5265b-d1e4-4fe1-9a46-0ea36f8afced.png", + }, + title: { + raw: "Keep pesky trackers off your tail", + fontSize: "22px", + fontWeight: 590, + paddingBlock: "4px 0", + letterSpacing: 0, + paddingInline: "24px", + }, + subtitle: { + raw: "Say goodbye to annoying ad trackers and settle into a safer, speedy internet experience.", + fontSize: "15px", + lineHeight: "1.4", + marginBlock: "8px 16px", + letterSpacing: 0, + paddingInline: "24px", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + primary_button: { + label: { + raw: "Open my links with Firefox", + marginBlock: "4px 0", + paddingBlock: "0", + paddingInline: "16px", + }, + action: { + type: "SET_DEFAULT_BROWSER", + navigate: true, + }, + }, + secondary_button: { + label: { + raw: "Not now", + marginBlock: "0 -20px", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + backdrop: "transparent", + template: "multistage", + transitions: true, + }, + trigger: { + id: "defaultBrowserCheck", + }, + priority: 1, + template: "spotlight", + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !willShowDefaultPrompt && !isMajorUpgrade && !activeNotifications && (((currentDate|date) - (profileAgeCreated|date)) / 3600000 >= 6) && !isDefaultBrowser", + }, + ]; + }, +}; diff --git a/browser/components/asrouter/tests/browser/browser.toml b/browser/components/asrouter/tests/browser/browser.toml new file mode 100644 index 0000000000..7bed40373d --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser.toml @@ -0,0 +1,44 @@ +[DEFAULT] +support-files = [ + "../../../newtab/test/browser/blue_page.html", + "head.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_targeting.js"] + +["browser_asrouter_toast_notification.js"] + +["browser_asrouter_toolbarbadge.js"] +tags = "remote-settings" +skip-if = ["a11y_checks"] # Bug 1854515 and 1858041 to investigate intermittent a11y_checks results (fails on Autoland, passes on Try) + +["browser_feature_callout_in_chrome.js"] +skip-if = [ + "os == 'mac' && debug", # Bug 1804349 + "win11_2009", # Bug 1804349 +] + +["browser_feature_callout_panel.js"] + +["browser_trigger_listeners.js"] +https_first_disabled = true diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js b/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js new file mode 100644 index 0000000000..19fcb63131 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_bug1761522.js @@ -0,0 +1,234 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ASRouter, MessageLoaderUtils } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/PanelTestProvider.sys.mjs" +); +const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); +const { RemoteL10n } = ChromeUtils.importESModule( + "resource:///modules/asrouter/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/asrouter/tests/browser/browser_asrouter_bug1800087.js b/browser/components/asrouter/tests/browser/browser_asrouter_bug1800087.js new file mode 100644 index 0000000000..ce4e673742 --- /dev/null +++ b/browser/components/asrouter/tests/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:///modules/asrouter/ASRouterNewTabHook.sys.mjs" +); +const { ASRouterDefaultConfig } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterDefaultConfig.sys.mjs" +); + +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/asrouter/tests/browser/browser_asrouter_cfr.js b/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js new file mode 100644 index 0000000000..0cab79994e --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_cfr.js @@ -0,0 +1,932 @@ +/* eslint-disable @microsoft/sdl/no-insecure-url */ +const { ASRouterTriggerListeners } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs" +); +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +const { TelemetryFeed } = ChromeUtils.importESModule( + "resource://activity-stream/lib/TelemetryFeed.sys.mjs" +); + +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() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.telemetry" + ); + // Reset fog to clear pings here for private window test later. + Services.fog.testResetFOG(); + }); + + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.telemetry", + true + ); + + Services.fog.testResetFOG(); + let pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal(Glean.messagingSystem.source.testGetValue(), "CFR"); + }); + + // addRecommendation checks that scheme starts with http and host matches + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + const 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(pingSubmitted, "Recorded an event"); +}); + +add_task(async function test_cfr_notification_show() { + // addRecommendation checks that scheme starts with http and host matches + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + 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.startLoadingURIString(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.startLoadingURIString(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.startLoadingURIString(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.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + event: "ContentBlockingMilestone", + }, + }, + "SiteProtection:ContentBlockingMilestone" + ); + + await showPanel; + + const notification = document.getElementById( + "contextual-feature-recommendation-notification" + ); + + checkCFRTrackingProtectionMilestone(notification); + + Assert.ok(notification.secondaryButton); + let hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + + notification.secondaryButton.click(); + await hidePanel; + await SpecialPowers.popPrefEnv(); + clearNotifications(); + } +); + +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.startLoadingURIString(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.startLoadingURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + BrowserTestUtils.startLoadingURIString(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.startLoadingURIString(browser, "http://example.com/#foo"); + await BrowserTestUtils.waitForLocationChange( + gBrowser, + "http://example.com/#foo" + ); + + Assert.equal(count, 1, "It should ignore same page navigation"); + + BrowserTestUtils.startLoadingURIString(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.startLoadingURIString(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.startLoadingURIString(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.startLoadingURIString(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.startLoadingURIString(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) { + 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.startLoadingURIString(browser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/"); + + const response = await trigger_cfr_panel(browser, "example.com"); + Assert.ok( + response, + "Should return true if addRecommendation checks were successful" + ); + + // Open the panel with the keyboard. + // Toolbar buttons aren't always focusable; toolbar keyboard navigation + // makes them focusable on demand. Therefore, we must force focus. + const button = document.getElementById("contextual-feature-recommendation"); + button.setAttribute("tabindex", "-1"); + button.focus(); + button.removeAttribute("tabindex"); + + let focused = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "focus", + true + ); + EventUtils.synthesizeKey(" "); + await focused; + Assert.ok(true, "Focus inside panel after button pressed"); + + let hidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + EventUtils.synthesizeKey("KEY_Escape"); + await hidden; + Assert.ok(true, "Panel hidden after Escape pressed"); + + const showPanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // Need to dismiss the notification to clear the RecommendationMap + document.getElementById("contextual-feature-recommendation").click(); + await showPanel; + + const hidePanel = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + document + .getElementById("contextual-feature-recommendation-notification") + .button.click(); + await hidePanel; +}); + +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() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.telemetry" + ); + }); + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.telemetry", + true + ); + + let pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal(Glean.messagingSystem.source.testGetValue(), "CFR"); + Assert.equal( + Glean.messagingSystem.messageId.testGetValue(), + "n/a", + "Omitted message_id consistent with CFR telemetry policy" + ); + Assert.equal( + Glean.messagingSystem.clientId.testGetValue(), + undefined, + "Omitted client_id consistent with CFR telemetry policy" + ); + }); + + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + 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.ok(pingSubmitted, "Submitted a CFR messaging system ping"); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js b/browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js new file mode 100644 index 0000000000..14f4dda54f --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_experimentsAPILoader.js @@ -0,0 +1,505 @@ +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.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 { ExperimentManager } = ChromeUtils.importESModule( + "resource://nimbus/lib/ExperimentManager.sys.mjs" +); +const { TelemetryFeed } = ChromeUtils.importESModule( + "resource://activity-stream/lib/TelemetryFeed.sys.mjs" +); +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/asrouter/tests/browser/browser_asrouter_group_frequency.js b/browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js new file mode 100644 index 0000000000..58f47ae6bf --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_group_frequency.js @@ -0,0 +1,188 @@ +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +/** + * 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() { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + 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.startLoadingURIString(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.startLoadingURIString(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.startLoadingURIString(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/asrouter/tests/browser/browser_asrouter_group_userprefs.js b/browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js new file mode 100644 index 0000000000..3bfc05ba48 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_group_userprefs.js @@ -0,0 +1,158 @@ +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +/** + * 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() { + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + 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.startLoadingURIString(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.startLoadingURIString(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/asrouter/tests/browser/browser_asrouter_infobar.js b/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js new file mode 100644 index 0000000000..b80b3ec7a4 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_infobar.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { InfoBar } = ChromeUtils.importESModule( + "resource:///modules/asrouter/InfoBar.sys.mjs" +); +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +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 = await 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.closeButtonEl.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 = await InfoBar.showInfoBarMessage( + BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + message, + dispatchStub + ); + + // Remove any IMPRESSION pings + dispatchStub.reset(); + + infobar.notification.closeButtonEl.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 = await 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 = await InfoBar.showInfoBarMessage( + BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + message, + dispatchStub + ); + + Assert.equal(dispatchStub.callCount, 2, "Called twice with IMPRESSION"); + + // Try to stack 2 notifications + await InfoBar.showInfoBarMessage( + BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser, + message, + dispatchStub + ); + + Assert.equal(dispatchStub.callCount, 2, "Impression count did not increase"); + + // Dismiss the first notification + infobar.notification.closeButtonEl.click(); + Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification"); + + // Reset impressions count + dispatchStub.reset(); + // Try show the message again + infobar = await 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.closeButtonEl.click(); + Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification"); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js b/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js new file mode 100644 index 0000000000..f752d01116 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_momentspagehub.js @@ -0,0 +1,116 @@ +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/PanelTestProvider.sys.mjs" +); +const { MomentsPageHub } = ChromeUtils.importESModule( + "resource:///modules/asrouter/MomentsPageHub.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +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/asrouter/tests/browser/browser_asrouter_targeting.js b/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js new file mode 100644 index 0000000000..432b4b75a7 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_targeting.js @@ -0,0 +1,1706 @@ +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", + ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + AttributionCode: "resource:///modules/AttributionCode.sys.mjs", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs", + CFRMessageProvider: "resource:///modules/asrouter/CFRMessageProvider.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs", + HomePage: "resource:///modules/HomePage.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", + QueryCache: "resource:///modules/asrouter/ASRouterTargeting.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", + }) + ).records; + 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", + }) + ).records; + 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({ records: [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() { + const PREF_GETTER_MAPPING = new Map([ + ["browser.migrate.interactions.bookmarks", "hasMigratedBookmarks"], + ["browser.migrate.interactions.csvpasswords", "hasMigratedCSVPasswords"], + ["browser.migrate.interactions.history", "hasMigratedHistory"], + ["browser.migrate.interactions.passwords", "hasMigratedPasswords"], + ]); + + for (let [pref, getterName] of PREF_GETTER_MAPPING) { + await pushPrefs([pref, false]); + ok(!(await ASRouterTargeting.Environment[getterName])); + await pushPrefs([pref, true]); + ok(await ASRouterTargeting.Environment[getterName]); + } +}); + +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(); +}); + +add_task(async function check_primaryResolution() { + is( + typeof ASRouterTargeting.Environment.primaryResolution, + "object", + "Should return an object" + ); + + is( + typeof ASRouterTargeting.Environment.primaryResolution.width, + "number", + "Width property should return a number" + ); + + is( + typeof ASRouterTargeting.Environment.primaryResolution.height, + "number", + "Height property should return a number" + ); +}); + +add_task(async function check_archBits() { + const bits = ASRouterTargeting.Environment.archBits; + is(typeof bits, "number", "archBits should be a number"); + ok(bits === 32 || bits === 64, "archBits is either 32 or 64"); +}); + +add_task(async function check_memoryMB() { + const memory = ASRouterTargeting.Environment.memoryMB; + is(typeof memory, "number", "Memory is a number"); + // To make sure we get a sensible number we verify that whatever system + // runs this unit test it has between 500MB and 1TB of RAM. + ok(memory > 500 && memory < 5_000_000); +}); diff --git a/browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js b/browser/components/asrouter/tests/browser/browser_asrouter_toast_notification.js new file mode 100644 index 0000000000..2c1adb477b --- /dev/null +++ b/browser/components/asrouter/tests/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.importESModule( + "resource:///modules/asrouter/ToastNotification.sys.mjs" +); +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/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/asrouter/tests/browser/browser_asrouter_toolbarbadge.js b/browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js new file mode 100644 index 0000000000..90e0d5ceeb --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_asrouter_toolbarbadge.js @@ -0,0 +1,149 @@ +const { OnboardingMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs" +); +const { ToolbarBadgeHub } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ToolbarBadgeHub.sys.mjs" +); + +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/asrouter/tests/browser/browser_feature_callout_in_chrome.js b/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js new file mode 100644 index 0000000000..b7169d6be7 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_feature_callout_in_chrome.js @@ -0,0 +1,1122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +const { DefaultBrowserCheck } = ChromeUtils.importESModule( + "resource:///modules/BrowserGlue.sys.mjs" +); + +const PDF_TEST_URL = + "https://example.com/browser/browser/components/newtab/test/browser/file_pdf.PDF"; + +async function openURLInWindow(window, url) { + const { selectedBrowser } = window.gBrowser; + BrowserTestUtils.startLoadingURIString(selectedBrowser, url); + await BrowserTestUtils.browserLoaded(selectedBrowser, false, url); + return selectedBrowser; +} + +async function openURLInNewTab(window, ...args) { + return BrowserTestUtils.openNewForegroundTab(window.gBrowser, ...args); +} + +const pdfMatch = sinon.match(val => { + return ( + val?.id === "pdfJsFeatureCalloutCheck" && val?.context?.source === "open" + ); +}); + +const validateCalloutCustomPosition = (element, absolutePosition, doc) => { + const browserBox = doc.querySelector("hbox#browser"); + for (let position in absolutePosition) { + if (Object.prototype.hasOwnProperty.call(absolutePosition, position)) { + // remove the `px` at the end of our absolute position strings + const relativePos = parseFloat(absolutePosition[position]); + 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, absolutePosition) => { + for (let position in absolutePosition) { + if (Object.prototype.hasOwnProperty.call(absolutePosition, position)) { + const pixels = parseFloat(absolutePosition[position]); + if (position === "left") { + if (element.getBoundingClientRect().right !== pixels) { + return false; + } + } else if (position === "right") { + if (element.getBoundingClientRect().left !== pixels) { + 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", + anchors: [ + { selector: "#PanelUI-menu-button", arrow_position: "top-end" }, + ], + content: { + position: "callout", + title: { + raw: "Test title", + }, + subtitle: { + raw: "Test subtitle", + }, + primary_button: { + label: { + raw: "Done", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + priority: 1, + targeting: "true", + trigger: { id: "pdfJsFeatureCalloutCheck" }, + }, +}; + +const newtabTestMessage = { + id: "TEST_MESSAGE", + template: "feature_callout", + content: { + id: "TEST_MESSAGE", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: "browser.newtab.feature-tour", + tour_pref_default_value: JSON.stringify({ + screen: "TEST_MESSAGE_1", + complete: false, + }), + screens: [ + { + id: "TEST_MESSAGE_1", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "45px", right: "55px" }, + }, + ], + content: { + position: "callout", + title: "Test callout title", + subtitle: "Test callout subtitle", + primary_button: { + label: "Test callout button", + }, + }, + }, + ], + }, + priority: 1, + targeting: "true", + trigger: { id: "newtabFeatureCalloutCheck" }, +}; + +const testMessageScreenId = testMessage.message.content.screens[0].id; +const newtabTestMessageScreenId = newtabTestMessage.content.screens[0].id; + +const inChaosMode = !!parseInt(Services.env.get("MOZ_CHAOSMODE"), 16); + +add_setup(async function () { + let timeoutFactor = 3; + // Runtime increases in chaos mode on Mac. + if (inChaosMode && AppConstants.platform === "macosx") { + timeoutFactor = 5; + } + requestLongerTimeout(timeoutFactor); +}); + +// Test that a feature callout message can be loaded into ASRouter and displayed +// via a standard trigger. Also test that the callout can be a feature tour, +// even if its tour pref doesn't exist in Firefox. The tour pref will be created +// and cleaned up automatically. This allows a feature callout to be implemented +// entirely off-train in an experiment, without landing anything in tree. +add_task(async function triggered_feature_tour_with_custom_pref() { + let sandbox = sinon.createSandbox(); + const TEST_MESSAGES = [ + { + id: "TEST_FEATURE_TOUR", + template: "feature_callout", + content: { + id: "TEST_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: "messaging-system-action.browser.test.feature-tour", + tour_pref_default_value: JSON.stringify({ + screen: "FEATURE_CALLOUT_1", + complete: false, + }), + screens: [ + { + id: "FEATURE_CALLOUT_1", + anchors: [ + { + selector: "#PanelUI-menu-button", + arrow_position: "top-center-arrow-end", + }, + ], + content: { + position: "callout", + title: { string_id: "callout-pdfjs-edit-title" }, + subtitle: { string_id: "callout-pdfjs-edit-body-b" }, + primary_button: { + label: { string_id: "callout-pdfjs-edit-button" }, + action: { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.browser.test.feature-tour", + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_2", + complete: false, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "BLOCK_MESSAGE", + data: { id: "TEST_FEATURE_TOUR" }, + }, + { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.browser.test.feature-tour", + }, + }, + }, + ], + }, + }, + }, + }, + }, + { + id: "FEATURE_CALLOUT_2", + anchors: [ + { + selector: "#back-button", + arrow_position: "top-center-arrow-start", + }, + ], + content: { + position: "callout", + title: { string_id: "callout-pdfjs-draw-title" }, + subtitle: { string_id: "callout-pdfjs-draw-body-b" }, + primary_button: { + label: { string_id: "callout-pdfjs-draw-button" }, + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "BLOCK_MESSAGE", + data: { id: "TEST_FEATURE_TOUR" }, + }, + { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.browser.test.feature-tour", + }, + }, + }, + ], + }, + }, + }, + dismiss_button: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "BLOCK_MESSAGE", + data: { id: "TEST_FEATURE_TOUR" }, + }, + { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.browser.test.feature-tour", + }, + }, + }, + ], + }, + }, + }, + }, + }, + ], + }, + priority: 2, + targeting: `(('messaging-system-action.browser.test.feature-tour' | preferenceValue) ? (('messaging-system-action.browser.test.feature-tour' | preferenceValue | regExpMatch('(?<=complete":)(.*)(?=})')) ? ('messaging-system-action.browser.test.feature-tour' | preferenceValue | regExpMatch('(?<=complete":)(.*)(?=})')[1] != "true") : true) : true)`, + trigger: { id: "nthTabClosed" }, + }, + { + id: "TEST_FEATURE_TOUR_2", + template: "feature_callout", + content: { + id: "TEST_FEATURE_TOUR_2", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FEATURE_CALLOUT_TEST", + anchors: [ + { + selector: "#PanelUI-menu-button", + arrow_position: "top-center-arrow-end", + }, + ], + content: { + position: "callout", + title: { string_id: "callout-pdfjs-edit-title" }, + subtitle: { string_id: "callout-pdfjs-edit-body-b" }, + primary_button: { + label: { string_id: "callout-pdfjs-edit-button" }, + action: { dismiss: true }, + }, + }, + }, + ], + }, + priority: 1, + targeting: "true", + trigger: { id: "nthTabClosed" }, + }, + ]; + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); + + // Test that callout is triggered and shown in browser chrome + const win1 = await BrowserTestUtils.openNewBrowserWindow(); + win1.focus(); + const tab1 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser); + await TestUtils.waitForTick(); + win1.gBrowser.removeTab(tab1); + await waitForCalloutScreen( + win1.document, + TEST_MESSAGES[0].content.screens[0].id + ); + ok( + win1.document.querySelector(calloutSelector), + "Feature Callout is rendered in the browser chrome when a message is available" + ); + + // Test that a callout does NOT appear if another is already shown in any window. + const showFeatureCalloutSpy = sandbox.spy( + FeatureCalloutBroker, + "showFeatureCallout" + ); + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + win2.focus(); + const tab2 = await BrowserTestUtils.openNewForegroundTab(win2.gBrowser); + await TestUtils.waitForTick(); + win2.gBrowser.removeTab(tab2); + await BrowserTestUtils.waitForCondition(async () => { + const rvs = await Promise.all(showFeatureCalloutSpy.returnValues); + return ( + showFeatureCalloutSpy.calledWith( + win2.gBrowser.selectedBrowser, + sinon.match(TEST_MESSAGES[0]) + ) && rvs.every(rv => !rv) + ); + }, "Waiting for showFeatureCallout to be called"); + ok( + !win2.document.querySelector(calloutSelector), + "Feature Callout is not rendered when a callout is already shown in any window" + ); + await BrowserTestUtils.closeWindow(win2); + win1.focus(); + await BrowserTestUtils.waitForCondition( + async () => Services.focus.activeWindow === win1, + "Waiting for window 1 to be active" + ); + + // Test that the tour pref doesn't exist yet + ok( + !Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name), + "Tour pref does not exist yet" + ); + + // Test that the callout advances screen and sets the tour pref + win1.document.querySelector(calloutCTASelector).click(); + await BrowserTestUtils.waitForCondition( + () => + Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name), + "Waiting for tour pref to be set" + ); + SimpleTest.isDeeply( + JSON.parse( + Services.prefs.getStringPref( + TEST_MESSAGES[0].content.tour_pref_name, + "{}" + ) + ), + { screen: "FEATURE_CALLOUT_2", complete: false }, + "Tour pref is set correctly" + ); + await waitForCalloutScreen( + win1.document, + TEST_MESSAGES[0].content.screens[1].id + ); + ok( + win1.document.querySelector(calloutSelector), + "Feature Callout screen 2 is rendered" + ); + + // Test that the callout is dismissed and cleans up the tour pref + win1.document.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(win1.document); + ok( + !win1.document.querySelector(calloutSelector), + "Feature Callout is not rendered after being dismissed" + ); + ok( + !Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name), + "Tour pref is cleaned up correctly" + ); + await BrowserTestUtils.waitForCondition( + () => !FeatureCalloutBroker.isCalloutShowing, + "Waiting for all callouts to empty from the callout broker" + ); + + // Test that the message was blocked so a different callout is shown + const tab3 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser); + await TestUtils.waitForTick(); + win1.gBrowser.removeTab(tab3); + await waitForCalloutScreen( + win1.document, + TEST_MESSAGES[1].content.screens[0].id + ); + ok( + win1.document.querySelector(calloutSelector), + "A different Feature Callout is rendered" + ); + win1.document.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(win1.document); + ok(!FeatureCalloutBroker.isCalloutShowing, "No Feature Callout is shown"); + + BrowserTestUtils.closeWindow(win1); + + sandbox.restore(); + await ASRouter.unblockMessageById(TEST_MESSAGES[0].id); + await ASRouter.resetMessageState(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); +}); + +add_task(async function callout_not_shown_if_dialog_open() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + let dialogPromise = BrowserTestUtils.promiseAlertDialog(null, undefined, { + callback: async dialogWin => { + let rv = await FeatureCalloutBroker.showFeatureCallout( + win.gBrowser.selectedBrowser, + testMessage.message + ); + ok( + !rv, + "Feature callout not shown when a dialog is open in the same window" + ); + dialogWin.document.querySelector("dialog").getButton("cancel").click(); + }, + isSubDialog: true, + }); + DefaultBrowserCheck.prompt(win); + await dialogPromise; + + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function callout_not_shown_if_panel_open() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const gCUITestUtils = new CustomizableUITestUtils(win); + await gCUITestUtils.openMainMenu(); + + let rv = await FeatureCalloutBroker.showFeatureCallout( + win.gBrowser.selectedBrowser, + testMessage.message + ); + ok(!rv, "Feature callout not shown when a panel is open in the same window"); + + await gCUITestUtils.hideMainMenu(); + await BrowserTestUtils.closeWindow(win); +}); + +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, testMessageScreenId); + 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 CTA to close + doc.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(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, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "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(`.${testMessageScreenId}`), + "Feature callout removed when tab without PDF URL is navigated to" + ); + + const tab3 = await openURLInNewTab(win, PDF_TEST_URL); + tab3.focus(); + await waitForCalloutScreen(doc, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "Feature callout still renders when opening a new tab with PDF url after being initially rendered on another tab" + ); + + tab1.focus(); + await waitForCalloutScreen(doc, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "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, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "Feature callout rendered when opening a new tab with PDF url" + ); + + BrowserTestUtils.startLoadingURIString(win.gBrowser, "about:preferences"); + await BrowserTestUtils.waitForLocationChange( + win.gBrowser, + "about:preferences" + ); + await waitForCalloutRemoved(doc); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "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, testMessageScreenId); + ok( + doc.querySelector(`.${testMessageScreenId}`), + "Feature callout rendered when opening a new tab with PDF url" + ); + + BrowserTestUtils.removeTab(tab1); + await waitForCalloutRemoved(doc); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "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 getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders( + ASRouter.state.providers.filter(p => p.id === "onboarding") + ); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + const doc = win.document; + + const tab1 = await BrowserTestUtils.addTab(win.gBrowser, PDF_TEST_URL); + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Feature callout not rendered when opening a background tab with PDF url" + ); + + BrowserTestUtils.removeTab(tab1); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Feature callout still not rendered after closing background tab with PDF url" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function newtab_feature_callout_appears_in_browser_chrome_on_newtab() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + const doc = win.document; + const tab1 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:newtab" + ); + tab1.focus(); + await waitForCalloutScreen(doc, newtabTestMessageScreenId); + ok( + doc.querySelector(`.${newtabTestMessageScreenId}`), + "Newtab feature callout rendered when opening a focused newtab" + ); + + BrowserTestUtils.removeTab(tab1); + await waitForCalloutRemoved(doc); + ok( + !doc.querySelector(`.${newtabTestMessageScreenId}`), + "Feature callout disappears after closing new tab" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function newtab_feature_callout_does_not_appear_when_opening_background_newtab_tab() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + const doc = win.document; + + await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:preferences" + ); + const tab2 = await BrowserTestUtils.addTab(win.gBrowser, "about:newtab"); + ok( + !doc.querySelector(`.${newtabTestMessageScreenId}`), + "Newtab feature callout not rendered when opening a background newtab" + ); + + BrowserTestUtils.removeTab(tab2); + await waitForCalloutRemoved(doc); + ok( + !doc.querySelector(`.${newtabTestMessageScreenId}`), + "Feature callout still not rendered after closing background tab" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function newtab_feature_callout_does_not_appear_in_browser_chrome_on_new_window() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(win, "about:newtab"); + const doc = win.document; + + await waitForCalloutScreen(doc, newtabTestMessageScreenId); + ok( + doc.querySelector(`.${newtabTestMessageScreenId}`), + "Newtab Feature Callout is in the browser chrome of first window when a message is available" + ); + + const win2 = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(win2, "about:newtab"); + const doc2 = win2.document; + ok( + !doc2.querySelector(`.${newtabTestMessageScreenId}`), + "Newtab Feature Callout is not in the browser chrome new window when a message is available" + ); + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(win2); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function feature_callout_disappears_when_navigating_from_newtab_to_pdf_url_in_same_tab() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + const doc = win.document; + const tab1 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:newtab" + ); + tab1.focus(); + await waitForCalloutScreen(doc, newtabTestMessageScreenId); + ok( + doc.querySelector(`.${newtabTestMessageScreenId}`), + "Feature callout rendered when opening a newtab" + ); + + BrowserTestUtils.startLoadingURIString(win.gBrowser, PDF_TEST_URL); + await BrowserTestUtils.waitForLocationChange(win.gBrowser, PDF_TEST_URL); + await waitForCalloutRemoved(doc); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Feature callout not rendered on original tab after navigating to PDF" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +add_task( + async function feature_callout_disappears_when_navigating_from_newtab_to_pdf_url_in_different_tab() { + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [newtabTestMessage]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + const doc = win.document; + const tab1 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "about:newtab" + ); + tab1.focus(); + await waitForCalloutScreen(doc, newtabTestMessageScreenId); + ok( + doc.querySelector(`.${newtabTestMessageScreenId}`), + "Feature callout rendered when opening a newtab" + ); + + const tab2 = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + PDF_TEST_URL + ); + tab2.focus(); + await waitForCalloutRemoved(doc); + + ok( + !doc.querySelector(`.${testMessageScreenId}`), + "Newtab feature callout not rendered after navigating to PDF" + ); + + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +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].anchors[0] = { + selector: "hbox#browser", + absolute_position: { top: "45px", right: "25px" }, + }; + + const sandbox = sinon.createSandbox(); + const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages"); + const TEST_MESSAGES = [pdfTestMessage.message]; + getMessagesStub.returns(TEST_MESSAGES); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + + 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].anchors[0].absolute_position, + 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"); + 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].anchors[0].absolute_position, + doc + ), + "Callout custom position is as expected while navigator toolbox height is extended" + ); + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); + await ASRouter._updateMessageProviders(); + await ASRouter.loadMessagesFromAllProviders(); + } +); + +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].anchors[0] = { + selector: "hbox#browser", + absolute_position: { 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"; + Assert.strictEqual( + 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].anchors[0].absolute_position + ), + "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, testMessageScreenId); + 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 waitForCalloutRemoved(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, testMessageScreenId); + 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(); + } +); + +add_task(async function first_anchor_selected_is_valid() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const config = { + win, + location: "chrome", + context: "chrome", + browser: win.gBrowser.selectedBrowser, + theme: { preset: "chrome" }, + }; + + const message = JSON.parse(JSON.stringify(testMessage.message)); + const sandbox = sinon.createSandbox(); + + const doc = win.document; + const featureCallout = new FeatureCallout(config); + const getAnchorSpy = sandbox.spy(featureCallout, "_getAnchor"); + featureCallout.showFeatureCallout(message); + await waitForCalloutScreen(doc, message.content.screens[0].id); + ok( + getAnchorSpy.alwaysReturned( + sandbox.match(message.content.screens[0].anchors[0]) + ), + "The first anchor is selected" + ); + + win.document.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(win.document); + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); +}); + +add_task(async function first_anchor_selected_is_invalid() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const doc = win.document; + const config = { + win, + location: "chrome", + context: "chrome", + browser: win.gBrowser.selectedBrowser, + theme: { preset: "chrome" }, + }; + + let stopReloadButton = doc.getElementById("stop-reload-button"); + + await gCustomizeMode.addToPanel(stopReloadButton); + + const message = JSON.parse(JSON.stringify(testMessage.message)); + message.content.screens[0].anchors = [ + // element that does not exist + { selector: "#some-fake-id.some-fake-class", arrow_position: "top" }, + // element that exists but has no height/width + { selector: "#a11y-announcement", arrow_position: "top" }, + // element that exists but is hidden by CSS + { selector: "#window-modal-dialog", arrow_position: "top" }, + // customizable widget that's in the overflow panel + { selector: "#stop-reload-button", arrow_position: "top" }, + // element that is fully visible + { selector: "#PanelUI-menu-button", arrow_position: "top" }, + ]; + const sandbox = sinon.createSandbox(); + + const featureCallout = new FeatureCallout(config); + const getAnchorSpy = sandbox.spy(featureCallout, "_getAnchor"); + featureCallout.showFeatureCallout(message); + await waitForCalloutScreen(doc, message.content.screens[0].id); + is( + getAnchorSpy.lastCall.returnValue.selector, + message.content.screens[0].anchors[4].selector, + "The first valid anchor (anchor 5) is selected" + ); + + win.document.querySelector(calloutCTASelector).click(); + await waitForCalloutRemoved(win.document); + CustomizableUI.reset(); + await BrowserTestUtils.closeWindow(win); + sandbox.restore(); +}); diff --git a/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js b/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js new file mode 100644 index 0000000000..1f87c71ec7 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_feature_callout_panel.js @@ -0,0 +1,430 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getTestMessage() { + return { + id: "TEST_PANEL_FEATURE_CALLOUT", + template: "feature_callout", + groups: [], + content: { + id: "TEST_PANEL_FEATURE_CALLOUT", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "TEST_PANEL_FEATURE_CALLOUT", + anchors: [ + { + selector: "#PanelUI-menu-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + }, + ], + content: { + position: "callout", + title: { raw: "Panel Feature Callout" }, + dismiss_button: { + action: { dismiss: true }, + }, + }, + }, + ], + }, + }; +} + +/** + * Set up a callout and show it. + * + * @param {MozBrowser} browser Probably the selected browser in the top window. + * @param {object} message The message to show. + * @returns {Promise<{featureCallout: FeatureCallout, showing: boolean, closed: Promise}>} + * A promise that resolves to an object containing the FeatureCallout + * instance, a boolean for whether the callout started showing correctly, and + * a promise that resolves when the callout is closed. + */ +async function showFeatureCallout(browser, message) { + let resolveClosed; + let closed = new Promise(resolve => { + resolveClosed = resolve; + }); + const config = { + win: browser.ownerGlobal, + location: "chrome", + context: "chrome", + browser, + theme: { preset: "chrome" }, + listener: (_, event) => { + if (event === "end") { + resolveClosed(); + } + }, + }; + const featureCallout = new FeatureCallout(config); + let showing = await featureCallout.showFeatureCallout(message); + return { featureCallout, showing, closed }; +} + +/** + * Make a new window, open a feature callout in it, run a function to hide the + * callout, and assert that the callout is hidden correctly. Optionally run a + * function after the callout is closed, for additional assertions. Finally, + * close the window. + * + * @param {function(Window, Element, FeatureCallout)} hideFn A function that + * hides the callout. Passed the following params: window, callout container, + * and FeatureCallout instance. + * @param {function(Window, Element, FeatureCallout)} afterCloseFn An optional + * function to run after the callout is closed. Same params as hideFn. + * @param {object} message The message to show. + */ +async function testCalloutHiddenIf( + hideFn, + afterCloseFn, + message = getTestMessage() +) { + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.focus(); + const doc = win.document; + const browser = win.gBrowser.selectedBrowser; + const { featureCallout, showing, closed } = await showFeatureCallout( + browser, + message + ); + + await waitForCalloutScreen(doc, message.content.screens[0].id); + let calloutContainer = featureCallout._container; + ok(showing && calloutContainer, "Feature callout should be showing"); + + await hideFn(win, calloutContainer, featureCallout); + + await closed; + await waitForCalloutRemoved(doc); + ok(!doc.querySelector(calloutSelector), "Feature callout should be hidden"); + + await afterCloseFn?.(win, calloutContainer, featureCallout); + await BrowserTestUtils.closeWindow(win); +} + +// Test that the callout is correctly created as a panel and positioned. +add_task(async function panel_feature_callout() { + await testCalloutHiddenIf(async (win, calloutContainer) => { + is(calloutContainer.localName, "panel", "Callout container is a panel"); + await BrowserTestUtils.waitForMutationCondition( + calloutContainer, + { attributeFilter: ["arrow-position"] }, + () => calloutContainer.getAttribute("arrow-position") === "top-end" + ); + is( + calloutContainer.anchorNode.id, + "PanelUI-menu-button", + "Callout container is anchored to the app menu button" + ); + is( + calloutContainer.getAttribute("arrow-position"), + "top-end", + "Callout container arrow is positioned correctly" + ); + + win.document.querySelector(calloutDismissSelector).click(); + }); +}); + +// Test that the callout is hidden if another popup is shown. +add_task(async function panel_feature_callout_hidden_on_popupshowing() { + await testCalloutHiddenIf(async win => { + // Click the app menu button to open the panel. + win.document.querySelector("#PanelUI-menu-button").click(); + }); +}); + +// Test that the callout is hidden if its anchor node is hidden. +add_task(async function panel_feature_callout_hidden_on_anchor_hidden() { + await testCalloutHiddenIf(async win => { + // Hide the app menu button. + win.document.querySelector("#PanelUI-menu-button").hidden = true; + }); +}); + +// Panels automatically track the movement of their anchor nodes, so test that +// the callout moves with its anchor node. +add_task(async function panel_feature_callout_follows_anchor() { + await testCalloutHiddenIf(async (win, calloutContainer) => { + let startingX = calloutContainer.getBoundingClientRect().x; + + // Move the app menu button away from the right edge of the window. + calloutContainer.anchorNode.style.marginInlineEnd = "100px"; + + // Wait for the callout to reposition itself. + await BrowserTestUtils.waitForCondition( + () => calloutContainer.getBoundingClientRect().x !== startingX, + "Callout should reposition itself" + ); + + win.document.querySelector(calloutDismissSelector).click(); + }); +}); + +// Panels normally set the `[open]` attribute on their anchor node when they're +// open, so that the anchor node can be styled differently when the panel is +// open. Not every anchor node has styles for this, but e.g. chrome buttons do. +add_task(async function panel_feature_callout_anchor_open_attr() { + let anchor; + await testCalloutHiddenIf( + async (win, calloutContainer) => { + anchor = calloutContainer.anchorNode; + ok( + anchor.hasAttribute("open"), + "Callout container's anchor node should have its [open] attribute set" + ); + + win.document.querySelector(calloutDismissSelector).click(); + }, + (win, calloutContainer) => { + ok( + !anchor.hasAttribute("open"), + "Callout container's anchor node should not have its [open] attribute set" + ); + } + ); +}); + +// However, some panels don't want to set the `[open]` attribute on their anchor +// node. Sometimes the panel is more of a hint than a menu, and the `[open]` +// style could give the impression that it's a menu. Or the anchor might already +// have its `[open]` attribute set by something else, and we may not want to +// interfere with that. So this feature is configurable by adding the +// no_open_on_anchor property to the anchor. +add_task(async function panel_feature_callout_no_anchor_open_attr() { + let message = getTestMessage(); + message.content.screens[0].anchors[0].no_open_on_anchor = true; + await testCalloutHiddenIf( + async (win, calloutContainer) => { + let anchor = calloutContainer.anchorNode; + ok( + !anchor.hasAttribute("open"), + "Callout container's anchor node should not have its [open] attribute set" + ); + + win.document.querySelector(calloutDismissSelector).click(); + }, + null, + message + ); +}); + +add_task(async function feature_callout_split_dismiss_button() { + let message = getTestMessage(); + message.content.screens[0].content.secondary_button = { + label: { raw: "Advance" }, + action: { navigate: true }, + }; + message.content.screens[0].content.submenu_button = { + submenu: [ + { + type: "action", + label: { raw: "Item 1" }, + action: { navigate: true }, + id: "item1", + }, + { + type: "action", + label: { raw: "Item 2" }, + action: { navigate: true }, + id: "item2", + }, + { + type: "menu", + label: { raw: "Menu 1" }, + submenu: [ + { + type: "action", + label: { raw: "Item 3" }, + action: { navigate: true }, + id: "item3", + }, + { + type: "action", + label: { raw: "Item 4" }, + action: { navigate: true }, + id: "item4", + }, + ], + id: "menu1", + }, + ], + attached_to: "secondary_button", + }; + + await testCalloutHiddenIf( + async (win, calloutContainer) => { + let splitButtonContainer = calloutContainer.querySelector( + `#${calloutId} .split-button-container` + ); + let secondaryButton = calloutContainer.querySelector( + `#${calloutId} .secondary:not(.submenu-button)` + ); + let submenuButton = calloutContainer.querySelector( + `#${calloutId} .submenu-button` + ); + let submenu = calloutContainer.querySelector( + `#${calloutId} .fxms-multi-stage-submenu` + ); + ok(splitButtonContainer, "Callout should have a split button container"); + ok(secondaryButton, "Callout should have a split secondary button"); + ok(submenuButton, "Callout should have a split submenu button"); + ok(submenu, "Callout should have a submenu"); + + // Click the submenu button and wait for the submenu (menupopup) to open. + let opened = BrowserTestUtils.waitForEvent(submenu, "popupshown"); + submenuButton.click(); + await opened; + + // Assert that all the menu items are present and that the order and + // structure is correct. + async function recursiveTestMenuItems(items, popup) { + let children = [...popup.children]; + for (let element of children) { + let index = children.indexOf(element); + let itemAtIndex = items[index]; + switch (element.localName) { + case "menuitem": + is( + itemAtIndex.type, + "action", + `Menu item ${itemAtIndex.id} should be an action` + ); + is( + JSON.stringify(element.config), + JSON.stringify(itemAtIndex), + `Menu item ${itemAtIndex.id} should have correct config` + ); + is( + element.value, + itemAtIndex.id, + `Menu item ${itemAtIndex.id} should have correct value` + ); + break; + case "menu": + is( + itemAtIndex.type, + "menu", + `Menu item ${itemAtIndex.id} should be a menu` + ); + is( + element.value, + itemAtIndex.id, + `Menu item ${itemAtIndex.id} should have correct value` + ); + info(`Testing submenu ${itemAtIndex.id}`); + await recursiveTestMenuItems( + itemAtIndex.submenu, + element.querySelector("menupopup") + ); + break; + case "menuseparator": + is( + itemAtIndex.type, + "separator", + `Menu item ${index} should be a separator` + ); + break; + default: + ok(false, "Child of unknown type in submenu"); + } + } + } + + info("Testing main menu"); + await recursiveTestMenuItems( + message.content.screens[0].content.submenu_button.submenu, + submenu + ); + + submenu.querySelector(`menuitem[value="item1"]`).click(); + }, + null, + message + ); +}); + +add_task(async function feature_callout_tab_order() { + let message = getTestMessage(); + message.content.screens[0].content.secondary_button = { + label: { raw: "Dismiss" }, + action: { dismiss: true }, + }; + message.content.screens[0].content.primary_button = { + label: { raw: "Advance" }, + action: { navigate: true }, + }; + + await testCalloutHiddenIf( + async (win, calloutContainer) => { + // Test that feature callout initially focuses the primary button. + let primaryButton = calloutContainer.querySelector( + `#${calloutId} .primary` + ); + await BrowserTestUtils.waitForCondition( + () => win.document.activeElement === primaryButton, + "Primary button should be focused" + ); + + // Test that pressing Tab loops through the primary button, secondary + // button, and dismiss button. + let secondaryButton = calloutContainer.querySelector( + `#${calloutId} .secondary` + ); + let onFocused2 = BrowserTestUtils.waitForEvent(secondaryButton, "focus"); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + await onFocused2; + is( + win.document.activeElement, + secondaryButton, + "Secondary button should be focused" + ); + + let dismissButton = calloutContainer.querySelector( + `#${calloutId} .dismiss-button` + ); + let onFocused3 = BrowserTestUtils.waitForEvent(dismissButton, "focus"); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + await onFocused3; + is( + win.document.activeElement, + dismissButton, + "Dismiss button should be focused" + ); + + let onFocused4 = BrowserTestUtils.waitForEvent(primaryButton, "focus"); + EventUtils.synthesizeKey("KEY_Tab", {}, win); + await onFocused4; + is( + win.document.activeElement, + primaryButton, + "Primary button should be focused" + ); + + // Test that pressing Shift+Tab loops back to the dismiss button. + let onFocused5 = BrowserTestUtils.waitForEvent(dismissButton, "focus"); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, win); + await onFocused5; + is( + win.document.activeElement, + dismissButton, + "Dismiss button should be focused" + ); + + EventUtils.synthesizeKey("VK_SPACE", {}, win); + }, + + null, + message + ); +}); diff --git a/browser/components/asrouter/tests/browser/browser_trigger_listeners.js b/browser/components/asrouter/tests/browser/browser_trigger_listeners.js new file mode 100644 index 0000000000..7c86645221 --- /dev/null +++ b/browser/components/asrouter/tests/browser/browser_trigger_listeners.js @@ -0,0 +1,430 @@ +/* eslint-disable @microsoft/sdl/no-insecure-url */ +const { ASRouterTriggerListeners } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs" +); + +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +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); + +async function waitForUrlLoad(url) { + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, url); + await BrowserTestUtils.browserLoaded(browser, false, url); +} + +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; +}); + +add_task(async function test_cookieBannerHandled() { + const handlerStub = sinon.stub(); + const bannerHandledTrigger = ASRouterTriggerListeners.get( + "cookieBannerHandled" + ); + bannerHandledTrigger.uninit(); + bannerHandledTrigger.init(handlerStub); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.focus(); + let eventWait = BrowserTestUtils.waitForEvent(win, "cookiebannerhandled"); + win.windowUtils.dispatchEventToChromeOnly( + win, + new CustomEvent("cookiebannerhandled", { + bubbles: true, + cancelable: false, + detail: { + windowContext: { + rootFrameLoader: { ownerElement: win.gBrowser.selectedBrowser }, + }, + }, + }) + ); + await eventWait; + let closeWindow = BrowserTestUtils.closeWindow(win); + + Assert.ok( + handlerStub.called, + "Called after `cookiebannerhandled` event fires" + ); + + handlerStub.resetHistory(); + bannerHandledTrigger.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(); +}); + +add_task(async function test_pageActionInUrlbarTrigger() { + const sandbox = sinon.createSandbox(); + const receivedTrigger = new Promise(resolve => { + sandbox + .stub(ASRouter, "sendTriggerMessage") + .callsFake(({ id, context }) => { + if ( + id === "pageActionInUrlbar" && + context?.pageAction === "picture-in-picture-button" + ) { + resolve(true); + } + }); + }); + sandbox + .stub(PictureInPicture, "getEligiblePipVideoCount") + .returns({ totalPipCount: 1, totalPipDisabled: 0 }); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.videocontrols.picture-in-picture.enabled", true], + ["media.videocontrols.picture-in-picture.urlbar-button.enabled", true], + ], + }); + + PictureInPicture.updateUrlbarToggle(gBrowser.selectedBrowser); + + let pageAction = await receivedTrigger; + ok(pageAction, "pageActionInUrlbar trigger sent with PiP button id"); + + await SpecialPowers.popPrefEnv(); + sandbox.restore(); + + PictureInPicture.updateUrlbarToggle(gBrowser.selectedBrowser); +}); diff --git a/browser/components/asrouter/tests/browser/head.js b/browser/components/asrouter/tests/browser/head.js new file mode 100644 index 0000000000..dd5e451540 --- /dev/null +++ b/browser/components/asrouter/tests/browser/head.js @@ -0,0 +1,66 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", + FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs", + + FeatureCalloutBroker: + "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs", + + FeatureCalloutMessages: + "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs", + + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", +}); +XPCOMUtils.defineLazyModuleGetters(this, { + QueryCache: "resource:///modules/asrouter/ASRouterTargeting.jsm", +}); +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +// We import sinon here to make it available across all mochitest test files +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// Feature callout constants +const calloutId = "feature-callout"; +const calloutSelector = `#${calloutId}.featureCallout`; +const calloutCTASelector = `#${calloutId} :is(.primary, .secondary)`; +const calloutDismissSelector = `#${calloutId} .dismiss-button`; + +function pushPrefs(...prefs) { + return SpecialPowers.pushPrefEnv({ set: prefs }); +} + +async function clearHistoryAndBookmarks() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + QueryCache.expireAll(); +} + +/** + * Helper function to navigate and wait for page to load + * https://searchfox.org/mozilla-central/rev/314b4297e899feaf260e7a7d1a9566a218216e7a/testing/mochitest/BrowserTestUtils/BrowserTestUtils.sys.mjs#404 + */ +async function waitForUrlLoad(url) { + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, url); + await BrowserTestUtils.browserLoaded(browser, false, url); +} + +async function waitForCalloutScreen(target, screenId) { + await BrowserTestUtils.waitForMutationCondition( + target, + { childList: true, subtree: true, attributeFilter: ["class"] }, + () => target.querySelector(`${calloutSelector}:not(.hidden) .${screenId}`) + ); +} + +async function waitForCalloutRemoved(target) { + await BrowserTestUtils.waitForMutationCondition( + target, + { childList: true, subtree: true }, + () => !target.querySelector(calloutSelector) + ); +} diff --git a/browser/components/asrouter/tests/unit/ASRouter.test.js b/browser/components/asrouter/tests/unit/ASRouter.test.js new file mode 100644 index 0000000000..7df1449a14 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouter.test.js @@ -0,0 +1,2870 @@ +import { _ASRouter, MessageLoaderUtils } from "modules/ASRouter.sys.mjs"; +import { QueryCache } from "modules/ASRouterTargeting.sys.mjs"; +import { + FAKE_LOCAL_MESSAGES, + FAKE_LOCAL_PROVIDER, + FAKE_LOCAL_PROVIDERS, + FAKE_REMOTE_MESSAGES, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +} from "./constants"; +import { + ASRouterPreferences, + TARGETING_PREFERENCES, +} from "modules/ASRouterPreferences.sys.mjs"; +import { ASRouterTriggerListeners } from "modules/ASRouterTriggerListeners.sys.mjs"; +import { CFRPageActions } from "modules/CFRPageActions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; +import ProviderResponseSchema from "content-src/schemas/provider-response.schema.json"; + +const MESSAGE_PROVIDER_PREF_NAME = + "browser.newtabpage.activity-stream.asrouter.providers.cfr"; +const FAKE_PROVIDERS = [ + FAKE_LOCAL_PROVIDER, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +]; +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; +const FAKE_RESPONSE_HEADERS = { get() {} }; +const FAKE_BUNDLE = [FAKE_LOCAL_MESSAGES[1], FAKE_LOCAL_MESSAGES[2]]; + +const USE_REMOTE_L10N_PREF = + "browser.newtabpage.activity-stream.asrouter.useRemoteL10n"; + +// eslint-disable-next-line max-statements +describe("ASRouter", () => { + let Router; + let globals; + let sandbox; + let initParams; + let messageBlockList; + let providerBlockList; + let messageImpressions; + let groupImpressions; + let previousSessionEnd; + let fetchStub; + let clock; + let fakeAttributionCode; + let fakeTargetingContext; + let FakeToolbarBadgeHub; + let FakeToolbarPanelHub; + let FakeMomentsPageHub; + let ASRouterTargeting; + let screenImpressions; + + function setMessageProviderPref(value) { + sandbox.stub(ASRouterPreferences, "providers").get(() => value); + } + + function initASRouter(router) { + const getStub = sandbox.stub(); + getStub.returns(Promise.resolve()); + getStub + .withArgs("messageBlockList") + .returns(Promise.resolve(messageBlockList)); + getStub + .withArgs("providerBlockList") + .returns(Promise.resolve(providerBlockList)); + getStub + .withArgs("messageImpressions") + .returns(Promise.resolve(messageImpressions)); + getStub.withArgs("groupImpressions").resolves(groupImpressions); + getStub + .withArgs("previousSessionEnd") + .returns(Promise.resolve(previousSessionEnd)); + getStub + .withArgs("screenImpressions") + .returns(Promise.resolve(screenImpressions)); + initParams = { + storage: { + get: getStub, + set: sandbox.stub().returns(Promise.resolve()), + }, + sendTelemetry: sandbox.stub().resolves(), + clearChildMessages: sandbox.stub().resolves(), + clearChildProviders: sandbox.stub().resolves(), + updateAdminState: sandbox.stub().resolves(), + dispatchCFRAction: sandbox.stub().resolves(), + }; + sandbox.stub(router, "loadMessagesFromAllProviders").callThrough(); + return router.init(initParams); + } + + async function createRouterAndInit(providers = FAKE_PROVIDERS) { + setMessageProviderPref(providers); + // `.freeze` to catch any attempts at modifying the object + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + await initASRouter(Router); + } + + beforeEach(async () => { + globals = new GlobalOverrider(); + messageBlockList = []; + providerBlockList = []; + messageImpressions = {}; + groupImpressions = {}; + previousSessionEnd = 100; + screenImpressions = {}; + sandbox = sinon.createSandbox(); + ASRouterTargeting = { + isMatch: sandbox.stub(), + findMatchingMessage: sandbox.stub(), + Environment: { + locale: "en-US", + localeLanguageCode: "en", + browserSettings: { + update: { + channel: "default", + enabled: true, + autoDownload: true, + }, + }, + attributionData: {}, + currentDate: "2000-01-01T10:00:0.001Z", + profileAgeCreated: {}, + profileAgeReset: {}, + usesFirefoxSync: false, + isFxAEnabled: true, + isFxASignedIn: false, + sync: { + desktopDevices: 0, + mobileDevices: 0, + totalDevices: 0, + }, + xpinstallEnabled: true, + addonsInfo: {}, + searchEngines: {}, + isDefaultBrowser: false, + devToolsOpenedCount: 5, + topFrecentSites: {}, + recentBookmarks: {}, + pinnedSites: [ + { + url: "https://amazon.com", + host: "amazon.com", + searchTopSite: true, + }, + ], + providerCohorts: { + onboarding: "", + cfr: "", + "message-groups": "", + "messaging-experiments": "", + "whats-new-panel": "", + }, + totalBookmarksCount: {}, + firefoxVersion: 80, + region: "US", + needsUpdate: {}, + hasPinnedTabs: false, + hasAccessedFxAPanel: false, + isWhatsNewPanelEnabled: true, + userPrefs: { + cfrFeatures: true, + cfrAddons: true, + }, + totalBlockedCount: {}, + blockedCountByType: {}, + attachedFxAOAuthClients: [], + platformName: "macosx", + scores: {}, + scoreThreshold: 5000, + isChinaRepack: false, + userId: "adsf", + }, + }; + + ASRouterPreferences.specialConditions = { + someCondition: true, + }; + sandbox.spy(ASRouterPreferences, "init"); + sandbox.spy(ASRouterPreferences, "uninit"); + sandbox.spy(ASRouterPreferences, "addListener"); + sandbox.spy(ASRouterPreferences, "removeListener"); + + clock = sandbox.useFakeTimers(); + fetchStub = sandbox + .stub(global, "fetch") + .withArgs("http://fake.com/endpoint") + .resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: FAKE_REMOTE_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + sandbox.stub(global.Services.prefs, "getStringPref"); + + fakeAttributionCode = { + allowedCodeKeys: ["foo", "bar", "baz"], + _clearCache: () => sinon.stub(), + getAttrDataAsync: () => Promise.resolve({ content: "addonID" }), + deleteFileAsync: () => Promise.resolve(), + writeAttributionFile: () => Promise.resolve(), + getCachedAttributionData: sinon.stub(), + }; + FakeToolbarPanelHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + forceShowMessage: sandbox.stub(), + enableToolbarButton: sandbox.stub(), + }; + FakeToolbarBadgeHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + registerBadgeNotificationListener: sandbox.stub(), + }; + FakeMomentsPageHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + executeAction: sandbox.stub(), + }; + fakeTargetingContext = { + combineContexts: sandbox.stub(), + evalWithDefault: sandbox.stub().resolves(), + }; + let fakeNimbusFeatures = [ + "cfr", + "infobar", + "spotlight", + "moments-page", + "pbNewtab", + ].reduce((features, featureId) => { + features[featureId] = { + getAllVariables: sandbox.stub().returns(null), + recordExposureEvent: sandbox.stub(), + }; + return features; + }, {}); + globals.set({ + // Testing framework doesn't know how to `defineLazyModuleGetters` so we're + // importing these modules into the global scope ourselves. + GroupsConfigurationProvider: { getMessages: () => Promise.resolve([]) }, + ASRouterPreferences, + TARGETING_PREFERENCES, + ASRouterTargeting, + ASRouterTriggerListeners, + QueryCache, + gBrowser: { selectedBrowser: {} }, + gURLBar: {}, + isSeparateAboutWelcome: true, + AttributionCode: fakeAttributionCode, + PanelTestProvider, + MacAttribution: { applicationPath: "" }, + ToolbarBadgeHub: FakeToolbarBadgeHub, + ToolbarPanelHub: FakeToolbarPanelHub, + MomentsPageHub: FakeMomentsPageHub, + KintoHttpClient: class { + bucket() { + return this; + } + collection() { + return this; + } + getRecord() { + return Promise.resolve({ data: {} }); + } + }, + Downloader: class { + download() { + return Promise.resolve("/path/to/download"); + } + }, + NimbusFeatures: fakeNimbusFeatures, + ExperimentAPI: { + getExperimentMetaData: sandbox.stub().returns({ + slug: "experiment-slug", + active: true, + branch: { slug: "experiment-branch-slug" }, + }), + getExperiment: sandbox.stub().returns({ + branch: { + slug: "unit-slug", + feature: { + featureId: "foo", + value: { id: "test-message" }, + }, + }, + }), + getAllBranches: sandbox.stub().resolves([]), + ready: sandbox.stub().resolves(), + }, + SpecialMessageActions: { + handleAction: sandbox.stub(), + }, + TargetingContext: class { + static combineContexts(...args) { + return fakeTargetingContext.combineContexts.apply(sandbox, args); + } + + evalWithDefault(expr) { + return fakeTargetingContext.evalWithDefault(expr); + } + }, + RemoteL10n: { + // This is just a subset of supported locales that happen to be used in + // the test. + isLocaleSupported: locale => ["en-US", "ja-JP-mac"].includes(locale), + }, + }); + await createRouterAndInit(); + }); + afterEach(() => { + Router.uninit(); + ASRouterPreferences.uninit(); + sandbox.restore(); + globals.restore(); + }); + + describe(".state", () => { + it("should throw if an attempt to set .state was made", () => { + assert.throws(() => { + Router.state = {}; + }); + }); + }); + + describe("#init", () => { + it("should only be called once", async () => { + Router = new _ASRouter(); + let state = await initASRouter(Router); + + assert.equal(state, Router.state); + + assert.isNull(await Router.init({})); + }); + it("should only be called once", async () => { + Router = new _ASRouter(); + initASRouter(Router); + let secondCall = await Router.init({}); + + assert.isNull( + secondCall, + "Should not init twice, it should exit early with null" + ); + }); + it("should set state.messageBlockList to the block list in persistent storage", async () => { + messageBlockList = ["foo"]; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageBlockList, ["foo"]); + }); + it("should initialize all the hub providers", async () => { + // ASRouter init called in `beforeEach` block above + + assert.calledOnce(FakeToolbarBadgeHub.init); + assert.calledOnce(FakeToolbarPanelHub.init); + assert.calledOnce(FakeMomentsPageHub.init); + + assert.calledWithExactly( + FakeToolbarBadgeHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + unblockMessageById: Router.unblockMessageById, + } + ); + + assert.calledWithExactly( + FakeToolbarPanelHub.init, + Router.waitForInitialized, + { + getMessages: Router.handleMessageRequest, + sendTelemetry: Router.sendTelemetry, + } + ); + + assert.calledWithExactly( + FakeMomentsPageHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + } + ); + }); + it("should set state.messageImpressions to the messageImpressions object in persistent storage", async () => { + // Note that messageImpressions are only kept if a message exists in router and has a .frequency property, + // otherwise they will be cleaned up by .cleanupImpressions() + const testMessage = { id: "foo", frequency: { lifetimeCap: 10 } }; + messageImpressions = { foo: [0, 1, 2] }; + setMessageProviderPref([ + { id: "onboarding", type: "local", messages: [testMessage] }, + ]); + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + it("should set state.screenImpressions to the screenImpressions object in persistent storage", async () => { + screenImpressions = { test: 123 }; + + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.screenImpressions, screenImpressions); + }); + it("should clear impressions for groups that are not active", async () => { + groupImpressions = { foo: [0, 1, 2] }; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.notProperty(Router.state.groupImpressions, "foo"); + }); + it("should keep impressions for groups that are active", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + lifetime: Infinity, + }, + }, + ], + groupImpressions: { foo: [Date.now()] }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should remove old impressions for a group", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + }, + }, + ], + groupImpressions: { + foo: [Date.now() - ONE_DAY_IN_MS - 1, Date.now()], + }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should await .loadMessagesFromAllProviders() and add messages from providers to state.messages", async () => { + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + + await initASRouter(Router); + + assert.calledOnce(Router.loadMessagesFromAllProviders); + assert.isArray(Router.state.messages); + assert.lengthOf( + Router.state.messages, + FAKE_LOCAL_MESSAGES.length + FAKE_REMOTE_MESSAGES.length + ); + }); + it("should set state.previousSessionEnd from IndexedDB", async () => { + previousSessionEnd = 200; + await createRouterAndInit(); + + assert.equal(Router.state.previousSessionEnd, previousSessionEnd); + }); + it("should assign ASRouterPreferences.specialConditions to state", async () => { + assert.isTrue(ASRouterPreferences.specialConditions.someCondition); + assert.isTrue(Router.state.someCondition); + }); + it("should add observer for `intl:app-locales-changed`", async () => { + sandbox.spy(global.Services.obs, "addObserver"); + await createRouterAndInit(); + + assert.calledWithExactly( + global.Services.obs.addObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should add a pref observer", async () => { + sandbox.spy(global.Services.prefs, "addObserver"); + await createRouterAndInit(); + + assert.calledOnce(global.Services.prefs.addObserver); + assert.calledWithExactly( + global.Services.prefs.addObserver, + USE_REMOTE_L10N_PREF, + Router + ); + }); + describe("lazily loading local test providers", () => { + afterEach(() => { + Router.uninit(); + }); + it("should add the local test providers on init if devtools are enabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + + await createRouterAndInit(); + + assert.property(Router._localProviders, "PanelTestProvider"); + }); + it("should not add the local test providers on init if devtools are disabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + + await createRouterAndInit(); + + assert.notProperty(Router._localProviders, "PanelTestProvider"); + }); + }); + }); + + describe("preference changes", () => { + it("should call ASRouterPreferences.init and add a listener on init", () => { + assert.calledOnce(ASRouterPreferences.init); + assert.calledWith(ASRouterPreferences.addListener, Router.onPrefChange); + }); + it("should call ASRouterPreferences.uninit and remove the listener on uninit", () => { + Router.uninit(); + assert.calledOnce(ASRouterPreferences.uninit); + assert.calledWith( + ASRouterPreferences.removeListener, + Router.onPrefChange + ); + }); + it("should send a AS_ROUTER_TARGETING_UPDATE message", async () => { + const messageTargeted = { + id: "1", + campaign: "foocampaign", + targeting: "true", + groups: ["cfr"], + provider: "cfr", + }; + const messageNotTargeted = { + id: "2", + campaign: "foocampaign", + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ + messages: [messageTargeted, messageNotTargeted], + providers: [{ id: "cfr" }], + }); + fakeTargetingContext.evalWithDefault.resolves(false); + + await Router.onPrefChange("services.sync.username"); + + assert.calledOnce(initParams.clearChildMessages); + assert.calledWith(initParams.clearChildMessages, [messageTargeted.id]); + }); + it("should call loadMessagesFromAllProviders on pref change", () => { + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should update groups state if a user pref changes", async () => { + await Router.setState({ + groups: [{ id: "foo", userPreferences: ["bar"], enabled: true }], + }); + sandbox.stub(ASRouterPreferences, "getUserPreference"); + + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledWithExactly(ASRouterPreferences.getUserPreference, "bar"); + }); + it("should update the list of providers on pref change", async () => { + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + url: "baz.com", + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + + const { length } = Router.state.providers; + + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + await Router._updateMessageProviders(); + + const provider = Router.state.providers.find(p => p.url === "baz.com"); + assert.lengthOf(Router.state.providers, length); + assert.isDefined(provider); + }); + it("should clear disabled providers on pref change", async () => { + const TEST_PROVIDER_ID = "some_provider_id"; + await Router.setState({ + providers: [{ id: TEST_PROVIDER_ID }], + }); + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + id: TEST_PROVIDER_ID, + enabled: false, + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledOnce(initParams.clearChildProviders); + assert.calledWith(initParams.clearChildProviders, [TEST_PROVIDER_ID]); + }); + }); + + describe("setState", () => { + it("should broadcast a message to update the admin tool on a state change if the asrouter.devtoolsEnabled pref is", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + sandbox.stub(Router, "getTargetingParameters").resolves({}); + const state = await Router.setState({ foo: 123 }); + + assert.calledOnce(initParams.updateAdminState); + assert.deepEqual(state.providerPrefs, ASRouterPreferences.providers); + assert.deepEqual( + state.userPrefs, + ASRouterPreferences.getAllUserPreferences() + ); + assert.deepEqual(state.targetingParameters, {}); + assert.deepEqual(state.errors, Router.errors); + }); + it("should not send a message on a state change asrouter.devtoolsEnabled pref is on", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + await Router.setState({ foo: 123 }); + + assert.notCalled(initParams.updateAdminState); + }); + }); + + describe("getTargetingParameters", () => { + it("should return the targeting parameters", async () => { + const stub = sandbox.stub().resolves("foo"); + const obj = { foo: 1 }; + sandbox.stub(obj, "foo").get(stub); + const result = await Router.getTargetingParameters(obj, obj); + + assert.calledTwice(stub); + assert.propertyVal(result, "foo", "foo"); + }); + }); + + describe("evaluateExpression", () => { + it("should call ASRouterTargeting to evaluate", async () => { + fakeTargetingContext.evalWithDefault.resolves("foo"); + const response = await Router.evaluateExpression({}); + assert.equal(response.evaluationStatus.result, "foo"); + assert.isTrue(response.evaluationStatus.success); + }); + it("should catch evaluation errors", async () => { + fakeTargetingContext.evalWithDefault.returns( + Promise.reject(new Error("fake error")) + ); + const response = await Router.evaluateExpression({}); + assert.isFalse(response.evaluationStatus.success); + }); + }); + + describe("#routeCFRMessage", () => { + let browser; + beforeEach(() => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + sandbox.stub(CFRPageActions, "addRecommendation"); + browser = {}; + }); + it("should route whatsnew_panel_message message to the right hub", () => { + Router.routeCFRMessage( + { template: "whatsnew_panel_message" }, + browser, + "", + true + ); + + assert.calledOnce(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route moments messages to the right hub", () => { + Router.routeCFRMessage({ template: "update_action" }, browser, "", true); + + assert.calledOnce(FakeMomentsPageHub.executeAction); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + }); + it("should route toolbar_badge message to the right hub", () => { + Router.routeCFRMessage({ template: "toolbar_badge" }, browser); + + assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route milestone_message to the right hub", () => { + Router.routeCFRMessage( + { template: "milestone_message" }, + browser, + "", + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_doorhanger" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = true", () => { + Router.routeCFRMessage({ template: "cfr_doorhanger" }, browser, {}, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + const { args } = CFRPageActions.addRecommendation.firstCall; + // Host should be null + assert.isNull(args[1]); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = true", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + {}, + true + ); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route default to sending to content", () => { + Router.routeCFRMessage( + { template: "some_other_template" }, + browser, + {}, + true + ); + + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + }); + + describe("#loadMessagesFromAllProviders", () => { + function assertRouterContainsMessages(messages) { + const messageIdsInRouter = Router.state.messages.map(m => m.id); + for (const message of messages) { + assert.include(messageIdsInRouter, message.id); + } + } + + it("should not trigger an update if not enough time has passed for a provider", async () => { + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + enabled: true, + url: "http://fake.com/endpoint", + updateCycleInMs: 300, + }, + ]); + + const previousState = Router.state; + + // Since we've previously gotten messages during init and we haven't advanced our fake timer, + // no updates should be triggered. + await Router.loadMessagesFromAllProviders(); + assert.deepEqual(Router.state, previousState); + }); + it("should not trigger an update if we only have local providers", async () => { + await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + + const previousState = Router.state; + const stub = sandbox.stub(MessageLoaderUtils, "loadMessagesForProvider"); + + clock.tick(300); + + await Router.loadMessagesFromAllProviders(); + + assert.deepEqual(Router.state, previousState); + assert.notCalled(stub); + }); + it("should update messages for a provider if enough time has passed, without removing messages for other providers", async () => { + const NEW_MESSAGES = [{ id: "new_123" }]; + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + url: "http://fake.com/endpoint", + enabled: true, + updateCycleInMs: 300, + }, + { + id: "alocalprovider", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + fetchStub.withArgs("http://fake.com/endpoint").resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: NEW_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + + clock.tick(301); + await Router.loadMessagesFromAllProviders(); + + // These are the new messages + assertRouterContainsMessages(NEW_MESSAGES); + // These are the local messages that should not have been deleted + assertRouterContainsMessages(FAKE_LOCAL_MESSAGES); + }); + it("should parse the triggers in the messages and register the trigger listeners", async () => { + sandbox.spy( + ASRouterTriggerListeners.get("openURL"), + "init" + ); /* eslint-disable object-property-newline */ + + /* eslint-disable object-curly-newline */ await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "foo", + template: "simple_template", + trigger: { id: "firstRun" }, + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "bar1", + template: "simple_template", + trigger: { + id: "openURL", + params: ["www.mozilla.org", "www.mozilla.com"], + }, + content: { title: "Bar1", body: "Bar123" }, + }, + { + id: "bar2", + template: "simple_template", + trigger: { id: "openURL", params: ["www.example.com"] }, + content: { title: "Bar2", body: "Bar123" }, + }, + ], + }, + ]); /* eslint-enable object-property-newline */ + /* eslint-enable object-curly-newline */ assert.calledTwice( + ASRouterTriggerListeners.get("openURL").init + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.mozilla.org", "www.mozilla.com"], + undefined + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.example.com"], + undefined + ); + }); + it("should parse the message's messagesLoaded trigger and immediately fire trigger", async () => { + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "bar3", + template: "simple_template", + trigger: { id: "messagesLoaded" }, + content: { title: "Bar3", body: "Bar123" }, + }, + ], + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle messages loading before a window or browser exists", async () => { + sandbox.stub(global, "gBrowser").value(undefined); + sandbox + .stub(global.Services.wm, "getMostRecentBrowserWindow") + .returns(undefined); + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + "whatsnew_panel_message", + "cfr_doorhanger", + "toolbar_badge", + "update_action", + "infobar", + "spotlight", + "toast_notification", + ].map((template, i) => { + return { + id: `foo${i}`, + template, + trigger: { id: "messagesLoaded" }, + content: { title: `Foo${i}`, body: "Bar123" }, + }; + }), + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle RemoteSettings blowing up and dispatch undesired event", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .rejects("fake error"); + await createRouterAndInit(); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_ERROR", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should dispatch undesired event if RemoteSettings returns no messages", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([]); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should download the attachment if RemoteSettings returns some messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + const spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledOnce(spy); + }); + it("should dispatch undesired event if the ms-language-packs returns no messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + sandbox + .stub(global.KintoHttpClient.prototype, "getRecord") + .resolves(null); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "ms-language-packs", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + }); + + describe("#_updateMessageProviders", () => { + it("should correctly replace %STARTPAGE_VERSION% in remote provider urls", async () => { + // If this test fails, you need to update the constant STARTPAGE_VERSION in + // ASRouter.sys.mjs to match the `version` property of provider-response-schema.json + const expectedStartpageVersion = ProviderResponseSchema.version; + const provider = { + id: "foo", + enabled: true, + type: "remote", + url: "https://www.mozilla.org/%STARTPAGE_VERSION%/", + }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.equal( + Router.state.providers[0].url, + `https://www.mozilla.org/${parseInt(expectedStartpageVersion, 10)}/` + ); + }); + it("should replace other params in remote provider urls by calling Services.urlFormater.formatURL", async () => { + const url = "https://www.example.com/"; + const replacedUrl = "https://www.foo.bar/"; + const stub = sandbox + .stub(global.Services.urlFormatter, "formatURL") + .withArgs(url) + .returns(replacedUrl); + const provider = { id: "foo", enabled: true, type: "remote", url }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.calledOnce(stub); + assert.calledWithExactly(stub, url); + assert.equal(Router.state.providers[0].url, replacedUrl); + }); + it("should only add the providers that are enabled", async () => { + const providers = [ + { + id: "foo", + enabled: false, + type: "remote", + url: "https://www.foo.com/", + }, + { + id: "bar", + enabled: true, + type: "remote", + url: "https://www.bar.com/", + }, + ]; + setMessageProviderPref(providers); + await Router._updateMessageProviders(); + assert.equal(Router.state.providers.length, 1); + assert.equal(Router.state.providers[0].id, providers[1].id); + }); + }); + + describe("#handleMessageRequest", () => { + beforeEach(async () => { + await Router.setState(() => ({ + providers: [{ id: "cfr" }, { id: "badge" }], + })); + }); + it("should not return a blocked message", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "cfr", groups: ["cfr"] }, + { id: "bar", provider: "cfr", groups: ["cfr"] }, + ], + messageBlockList: ["foo"], + })); + await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "cfr", groups: ["cfr"] }], + }); + }); + it("should not return a message from a disabled group", async () => { + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "cfr", groups: ["cfr"] }, + { id: "bar", provider: "cfr", groups: ["cfr"] }, + ], + groups: [{ id: "cfr", enabled: false }], + })); + const result = await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.isNull(result); + }); + it("should not return a message from a blocked campaign", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { + id: "foo", + provider: "cfr", + campaign: "foocampaign", + groups: ["cfr"], + }, + { id: "bar", provider: "cfr", groups: ["cfr"] }, + ], + messageBlockList: ["foocampaign"], + })); + + await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "cfr", groups: ["cfr"] }], + }); + }); + it("should not return a message excluded by the provider", async () => { + // There are only two providers; block the FAKE_LOCAL_PROVIDER, leaving + // only FAKE_REMOTE_PROVIDER unblocked, which provides only one message + await Router.setState(() => ({ + providers: [{ id: "cfr", exclude: ["foo"] }], + })); + + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "cfr" }], + messageBlockList: ["foocampaign"], + })); + + const result = await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.isNull(result); + }); + it("should not return a message if the frequency cap has been hit", async () => { + sandbox.stub(Router, "isBelowFrequencyCaps").returns(false); + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "cfr" }], + })); + const result = await Router.handleMessageRequest({ + provider: "cfr", + }); + assert.isNull(result); + }); + it("should get unblocked messages that match the trigger", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ triggerId: "foo" }); + + assert.deepEqual(result, message1); + }); + it("should get unblocked messages that match trigger and template", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + template: "badge", + trigger: { id: "foo" }, + groups: ["badge"], + provider: "badge", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + template: "test_template", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ + triggerId: "foo", + template: "badge", + }); + + assert.deepEqual(result, message1); + }); + it("should have messageImpressions in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "messageImpressions", + Router.state.messageImpressions + ); + }); + it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => { + const message1 = { + provider: "whats_new", + id: "1", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message2 = { + provider: "whats_new", + id: "2", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message3 = { + provider: "whats_new", + id: "3", + template: "badge", + groups: ["whats_new"], + }; + ASRouterTargeting.findMatchingMessage.callsFake(() => [ + message2, + message1, + ]); + await Router.setState({ + messages: [message3, message2, message1], + providers: [{ id: "whats_new" }], + }); + const result = await Router.handleMessageRequest({ + template: "whatsnew_panel_message", + triggerId: "whatsNewPanelOpened", + returnAll: true, + }); + + assert.deepEqual(result, [message2, message1]); + }); + it("should forward trigger param info", async () => { + const trigger = { + triggerId: "foo", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options.trigger, "id", trigger.triggerId); + assert.propertyVal(options.trigger, "param", trigger.triggerParam); + assert.propertyVal(options.trigger, "context", trigger.triggerContext); + }); + it("should not cache badge messages", async () => { + const trigger = { + triggerId: "bar", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + provider: "cfr", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options, "shouldCache", false); + }); + it("should filter out messages without a trigger (or different) when a triggerId is defined", async () => { + const trigger = { triggerId: "foo" }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["cfr"], + provider: "cfr", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["cfr"], + provider: "cfr", + }; + const message3 = { + id: "3", + campaign: "bazcampaign", + groups: ["cfr"], + provider: "cfr", + }; + await Router.setState({ + messages: [message2, message1, message3], + groups: [{ id: "cfr", enabled: true }], + }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake(args => args.messages); + + const result = Router.handleMessageRequest(trigger); + + assert.lengthOf(result, 1); + assert.deepEqual(result[0], message1); + }); + }); + + describe("#uninit", () => { + it("should unregister the trigger listeners", () => { + for (const listener of ASRouterTriggerListeners.values()) { + sandbox.spy(listener, "uninit"); + } + + Router.uninit(); + + for (const listener of ASRouterTriggerListeners.values()) { + assert.calledOnce(listener.uninit); + } + }); + it("should set .dispatchCFRAction to null", () => { + Router.uninit(); + assert.isNull(Router.dispatchCFRAction); + assert.isNull(Router.clearChildMessages); + assert.isNull(Router.sendTelemetry); + }); + it("should save previousSessionEnd", () => { + Router.uninit(); + + assert.calledOnce(Router._storage.set); + assert.calledWithExactly( + Router._storage.set, + "previousSessionEnd", + sinon.match.number + ); + }); + it("should remove the observer for `intl:app-locales-changed`", () => { + sandbox.spy(global.Services.obs, "removeObserver"); + Router.uninit(); + + assert.calledWithExactly( + global.Services.obs.removeObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should remove the pref observer for `USE_REMOTE_L10N_PREF`", async () => { + sandbox.spy(global.Services.prefs, "removeObserver"); + Router.uninit(); + + // Grab the last call as #uninit() also involves multiple calls of `Services.prefs.removeObserver`. + const call = global.Services.prefs.removeObserver.lastCall; + assert.calledWithExactly(call, USE_REMOTE_L10N_PREF, Router); + }); + }); + + describe("#setMessageById", async () => { + it("should send an empty message if provided id did not resolve to a message", async () => { + let response = await Router.setMessageById({ id: -1 }, true, {}); + assert.deepEqual(response.message, {}); + }); + }); + + describe("#isUnblockedMessage", () => { + it("should block a message if the group is blocked", async () => { + const msg = { id: "msg1", groups: ["foo"], provider: "unit-test" }; + await Router.setState({ + groups: [{ id: "foo", enabled: false }], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ groups: [{ id: "foo", enabled: true }] }); + + assert.isTrue(Router.isUnblockedMessage(msg)); + }); + it("should block a message if at least one group is blocked", async () => { + const msg = { + id: "msg1", + groups: ["foo", "bar"], + provider: "unit-test", + }; + await Router.setState({ + groups: [ + { id: "foo", enabled: false }, + { id: "bar", enabled: false }, + ], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ + groups: [ + { id: "foo", enabled: true }, + { id: "bar", enabled: false }, + ], + }); + + assert.isFalse(Router.isUnblockedMessage(msg)); + }); + }); + + describe("#blockMessageById", () => { + it("should add the id to the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + }); + it("should add the campaign to the messageBlockList instead of id if .campaign is specified and not select messages of that campaign again", async () => { + await Router.setState({ + messages: [ + { id: "1", campaign: "foocampaign" }, + { id: "2", campaign: "foocampaign" }, + ], + }); + await Router.blockMessageById("1"); + + assert.isTrue(Router.state.messageBlockList.includes("foocampaign")); + assert.isEmpty(Router.state.messages.filter(Router.isUnblockedMessage)); + }); + it("should be able to add multiple items to the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[0].id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[1].id)); + }); + it("should save the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.calledWithExactly(Router._storage.set, "messageBlockList", [ + FAKE_BUNDLE[0].id, + FAKE_BUNDLE[1].id, + ]); + }); + }); + + describe("#unblockMessageById", () => { + it("should remove the id from the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + await Router.unblockMessageById("foo"); + assert.isFalse(Router.state.messageBlockList.includes("foo")); + }); + it("should remove the campaign from the messageBlockList if it is defined", async () => { + await Router.setState({ messages: [{ id: "1", campaign: "foo" }] }); + await Router.blockMessageById("1"); + assert.isTrue( + Router.state.messageBlockList.includes("foo"), + "blocklist has campaign id" + ); + await Router.unblockMessageById("1"); + assert.isFalse( + Router.state.messageBlockList.includes("foo"), + "campaign id removed from blocklist" + ); + }); + it("should save the messageBlockList", async () => { + await Router.unblockMessageById("foo"); + assert.calledWithExactly(Router._storage.set, "messageBlockList", []); + }); + }); + + describe("#routeCFRMessage", () => { + it("should allow for echoing back message modifications", () => { + const message = { somekey: "some value" }; + const data = { content: message }; + const browser = {}; + let msg = Router.routeCFRMessage(data.content, browser, data, false); + assert.deepEqual(msg.message, message); + }); + it("should call CFRPageActions.forceRecommendation if the template is cfr_action and force is true", async () => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, null, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + }); + it("should call CFRPageActions.addRecommendation if the template is cfr_action and force is false", async () => { + sandbox.stub(CFRPageActions, "addRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, {}, false); + assert.calledOnce(CFRPageActions.addRecommendation); + }); + }); + + describe("#updateTargetingParameters", () => { + it("should return an object containing the whole state", async () => { + sandbox.stub(Router, "getTargetingParameters").resolves({}); + let msg = await Router.updateTargetingParameters(); + let expected = Object.assign({}, Router.state, { + providerPrefs: ASRouterPreferences.providers, + userPrefs: ASRouterPreferences.getAllUserPreferences(), + targetingParameters: {}, + errors: Router.errors, + devtoolsEnabled: ASRouterPreferences.devtoolsEnabled, + }); + + assert.deepEqual(msg, expected); + }); + }); + + describe("#reachEvent", () => { + let experimentAPIStub; + let featureIds = ["cfr", "moments-page", "infobar", "spotlight"]; + beforeEach(() => { + let getExperimentMetaDataStub = sandbox.stub(); + let getAllBranchesStub = sandbox.stub(); + featureIds.forEach(feature => { + global.NimbusFeatures[feature].getAllVariables.returns({ + id: `message-${feature}`, + }); + getExperimentMetaDataStub.withArgs({ featureId: feature }).returns({ + slug: `slug-${feature}`, + branch: { + slug: `branch-${feature}`, + }, + }); + getAllBranchesStub.withArgs(`slug-${feature}`).resolves([ + { + slug: `other-branch-${feature}`, + [feature]: { value: { trigger: "unit-test" } }, + }, + ]); + }); + experimentAPIStub = { + getExperimentMetaData: getExperimentMetaDataStub, + getAllBranches: getAllBranchesStub, + }; + globals.set("ExperimentAPI", experimentAPIStub); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should tag `forReachEvent` for all the expected message types", async () => { + // This should match the `providers.messaging-experiments` + let response = await MessageLoaderUtils.loadMessagesForProvider({ + type: "remote-experiments", + featureIds, + }); + + // 1 message for reach 1 for expose + assert.property(response, "messages"); + assert.lengthOf(response.messages, featureIds.length * 2); + assert.lengthOf( + response.messages.filter(m => m.forReachEvent), + featureIds.length + ); + }); + }); + + describe("#sendTriggerMessage", () => { + it("should pass the trigger to ASRouterTargeting when sending trigger message", async () => { + await Router.setState({ + messages: [ + { + id: "foo1", + provider: "onboarding", + template: "onboarding", + trigger: { id: "firstRun" }, + content: { title: "Foo1", body: "Foo123-1" }, + groups: ["onboarding"], + }, + ], + providers: [{ id: "onboarding" }], + }); + + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "firstRun", + }); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + assert.deepEqual( + ASRouterTargeting.findMatchingMessage.firstCall.args[0].trigger, + { + id: "firstRun", + param: undefined, + context: undefined, + } + ); + }); + it("should record telemetry information", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + + const tabId = 123; + + await Router.sendTriggerMessage({ + tabId, + browser: {}, + id: "firstRun", + }); + + assert.calledTwice(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + assert.calledTwice(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + }); + it("should have previousSessionEnd in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "previousSessionEnd", + 100 + ); + }); + it("should record the Reach event if found any", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "simple_template", + trigger: { id: "bar" }, + content: { title: "Foo2", body: "Foo123-2" }, + provider: "onboarding", + }, + { + id: "foo3", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp02", + branchSlug: "branch02", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledTwice(Services.telemetry.recordEvent); + }); + it("should not record the Reach event if it's already sent", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: true, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + assert.notCalled(Services.telemetry.recordEvent); + }); + it("should record the Exposure event for each valid feature", async () => { + ["cfr_doorhanger", "update_action", "infobar", "spotlight"].forEach( + async template => { + let featureMap = { + cfr_doorhanger: "cfr", + spotlight: "spotlight", + infobar: "infobar", + update_action: "moments-page", + }; + assert.notCalled( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + + let messages = [ + { + id: "foo1", + template, + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledOnce( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + } + ); + }); + }); + + describe("forceAttribution", () => { + let setAttributionString; + beforeEach(() => { + setAttributionString = sandbox.spy(Router, "setAttributionString"); + sandbox.stub(global.Services.env, "set"); + }); + afterEach(() => { + sandbox.reset(); + }); + it("should double encode on windows", async () => { + sandbox.stub(fakeAttributionCode, "writeAttributionFile"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.notCalled(setAttributionString); + assert.calledWithMatch( + fakeAttributionCode.writeAttributionFile, + "foo%3DFOO!%26bar%3DBAR%253F" + ); + }); + it("should set attribution string on mac", async () => { + sandbox.stub(global.AppConstants, "platform").value("macosx"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.calledOnce(setAttributionString); + assert.calledWithMatch( + setAttributionString, + "foo%3DFOO!%26bar%3DBAR%253F" + ); + }); + }); + + describe("#forceWNPanel", () => { + let browser = { + ownerGlobal: { + document: new Document(), + PanelUI: { + showSubView: sinon.stub(), + panel: { + setAttribute: sinon.stub(), + }, + }, + }, + }; + let fakePanel = { + setAttribute: sinon.stub(), + }; + sinon + .stub(browser.ownerGlobal.document, "getElementById") + .returns(fakePanel); + + it("should call enableToolbarButton", async () => { + await Router.forceWNPanel(browser); + assert.calledOnce(FakeToolbarPanelHub.enableToolbarButton); + assert.calledOnce(browser.ownerGlobal.PanelUI.showSubView); + assert.calledWith(fakePanel.setAttribute, "noautohide", true); + }); + }); + + describe("_triggerHandler", () => { + it("should call #sendTriggerMessage with the correct trigger", () => { + const getter = sandbox.stub(); + getter.returns(false); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ + id: "FAKE_TRIGGER", + param: "some fake param", + }) + ); + }); + }); + + describe("_triggerHandler_kiosk", () => { + it("should not call #sendTriggerMessage", () => { + const getter = sandbox.stub(); + getter.returns(true); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.notCalled(Router.sendTriggerMessage); + }); + }); + + describe("valid preview endpoint", () => { + it("should report an error if url protocol is not https", () => { + sandbox.stub(console, "error"); + + assert.equal(false, Router._validPreviewEndpoint("http://foo.com")); + assert.calledTwice(console.error); + }); + }); + + describe("impressions", () => { + describe("#addImpression for groups", () => { + it("should save an impression in each group-with-frequency in a message", async () => { + const fooMessageImpressions = [0]; + const aGroupImpressions = [0, 1, 2]; + const bGroupImpressions = [3, 4, 5]; + const cGroupImpressions = [6, 7, 8]; + + const message = { + id: "foo", + provider: "bar", + groups: ["a", "b", "c"], + }; + const groups = [ + { id: "a", frequency: { lifetime: 3 } }, + { id: "b", frequency: { lifetime: 4 } }, + { id: "c", frequency: { lifetime: 5 } }, + ]; + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.a = aGroupImpressions; + gImpressions.b = bGroupImpressions; + gImpressions.c = cGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.addImpression(message); + + assert.deepEqual( + Router.state.groupImpressions.a, + [0, 1, 2, 0], + "a impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.b, + [3, 4, 5, 0], + "b impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.c, + [6, 7, 8, 0], + "c impressions" + ); + }); + }); + + describe("#isBelowFrequencyCaps", () => { + it("should call #_isBelowItemFrequencyCap for the message and for the provider with the correct impressions and arguments", async () => { + sinon.spy(Router, "_isBelowItemFrequencyCap"); + + const MAX_MESSAGE_LIFETIME_CAP = 100; // Defined in ASRouter + const fooMessageImpressions = [0, 1]; + const barGroupImpressions = [0, 1, 2]; + + const message = { + id: "foo", + provider: "bar", + groups: ["bar"], + frequency: { lifetime: 3 }, + }; + const groups = [{ id: "bar", frequency: { lifetime: 5 } }]; + + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.bar = barGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.isBelowFrequencyCaps(message); + + assert.calledTwice(Router._isBelowItemFrequencyCap); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + message, + fooMessageImpressions, + MAX_MESSAGE_LIFETIME_CAP + ); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + groups[0], + barGroupImpressions + ); + }); + }); + + describe("#_isBelowItemFrequencyCap", () => { + it("should return false if the # of impressions exceeds the maxLifetimeCap", () => { + const item = { id: "foo", frequency: { lifetime: 5 } }; + const impressions = [0, 1]; + const maxLifetimeCap = 1; + const result = Router._isBelowItemFrequencyCap( + item, + impressions, + maxLifetimeCap + ); + assert.isFalse(result); + }); + + describe("lifetime frequency caps", () => { + it("should return true if .frequency is not defined on the item", () => { + const item = { id: "foo" }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if there are no impressions", () => { + const item = { + id: "foo", + frequency: { + lifetime: 10, + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + }, + }; + const impressions = []; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if the # of impressions is less than .frequency.lifetime of the item", () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if the # of impressions is equal to .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if the # of impressions is greater than .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2, 3]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + + describe("custom frequency caps", () => { + it("should return true if impressions in the time period < the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "foo", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if impressions in the time period > the cap and total impressions < the lifetime cap", () => { + clock.tick(200); + const item = { + id: "msg1", + frequency: { custom: [{ period: 100, cap: 2 }], lifetime: 3 }, + }; + const impressions = [0, 160, 161]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if impressions in one of the time periods > the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 200); + const itemTrue = { + id: "msg2", + frequency: { custom: [{ period: 100, cap: 2 }] }, + }; + const itemFalse = { + id: "msg1", + frequency: { + custom: [ + { period: 100, cap: 2 }, + { period: ONE_DAY_IN_MS, cap: 3 }, + ], + }, + }; + const impressions = [ + 0, + ONE_DAY_IN_MS + 160, + ONE_DAY_IN_MS - 100, + ONE_DAY_IN_MS - 200, + ]; + assert.isTrue(Router._isBelowItemFrequencyCap(itemTrue, impressions)); + assert.isFalse( + Router._isBelowItemFrequencyCap(itemFalse, impressions) + ); + }); + it("should return false if impressions in the time period < the cap and total impressions > the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return true if daily impressions < the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if daily impressions > the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [ + 0, + 1, + 2, + 3, + ONE_DAY_IN_MS + 1, + ONE_DAY_IN_MS + 2, + ONE_DAY_IN_MS + 3, + ]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + }); + + describe("#getLongestPeriod", () => { + it("should return the period if there is only one definition", () => { + const message = { + id: "foo", + frequency: { custom: [{ period: 200, cap: 2 }] }, + }; + assert.equal(Router.getLongestPeriod(message), 200); + }); + it("should return the longest period if there are more than one definitions", () => { + const message = { + id: "foo", + frequency: { + custom: [ + { period: 1000, cap: 3 }, + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }; + assert.equal(Router.getLongestPeriod(message), ONE_DAY_IN_MS); + }); + it("should return null if there are is no .frequency", () => { + const message = { id: "foo" }; + assert.isNull(Router.getLongestPeriod(message)); + }); + it("should return null if there are is no .frequency.custom", () => { + const message = { id: "foo", frequency: { lifetime: 10 } }; + assert.isNull(Router.getLongestPeriod(message)); + }); + }); + + describe("cleanup on init", () => { + it("should clear messageImpressions for messages which do not exist in state.messages", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + messageImpressions = { foo: [0], bar: [0, 1] }; + // Impressions for "bar" should be removed since that id does not exist in messages + const result = { foo: [0] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 5 }] }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the longest period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { + custom: [ + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions if they are not properly formatted", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + // this is impromperly formatted since messageImpressions are supposed to be an array + messageImpressions = { foo: 0 }; + const result = {}; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should not clear messageImpressions for messages which do exist in state.messages", async () => { + const messages = [ + { id: "foo", frequency: { lifetime: 10 } }, + { id: "bar", frequency: { lifetime: 10 } }, + ]; + messageImpressions = { foo: [0], bar: [] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.notCalled(Router._storage.set); + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + }); + }); + + describe("#_onLocaleChanged", () => { + it("should call _maybeUpdateL10nAttachment in the handler", async () => { + sandbox.spy(Router, "_maybeUpdateL10nAttachment"); + await Router._onLocaleChanged(); + + assert.calledOnce(Router._maybeUpdateL10nAttachment); + }); + }); + + describe("#_maybeUpdateL10nAttachment", () => { + it("should update the l10n attachment if the locale was changed", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + sandbox.spy(Router, "setState"); + Router.loadMessagesFromAllProviders.resetHistory(); + + await Router._maybeUpdateL10nAttachment(); + + assert.calledWith(Router.setState, { + localeInUse: "fr", + providers: [ + { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + lastUpdated: undefined, + errors: [], + }, + ], + }); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should not update the l10n attachment if the provider doesn't need l10n attachment", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "localProvider", + enabled: true, + type: "local", + }; + await createRouterAndInit([provider]); + Router.loadMessagesFromAllProviders.resetHistory(); + sandbox.spy(Router, "setState"); + + await Router._maybeUpdateL10nAttachment(); + + assert.notCalled(Router.setState); + assert.notCalled(Router.loadMessagesFromAllProviders); + }); + }); + describe("#observe", () => { + it("should reload l10n for CFRPageActions when the `USE_REMOTE_L10N_PREF` pref is changed", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", USE_REMOTE_L10N_PREF); + + assert.calledOnce(CFRPageActions.reloadL10n); + }); + it("should not react to other pref changes", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", "foo"); + + assert.notCalled(CFRPageActions.reloadL10n); + }); + }); + describe("#loadAllMessageGroups", () => { + it("should disable the group if the pref is false", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should enable the group if at least one pref is true", async () => { + sandbox + .stub(ASRouterPreferences, "getUserPreference") + .withArgs("cfrAddons") + .returns(false) + .withArgs("cfrFeatures") + .returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons", "cfrFeatures"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + }); + it("should be keep the group disabled if disabled is true", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: false, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should keep local groups unchanged if provider doesn't require an update", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(MessageLoaderUtils, "_loadDataForProvider"); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + // Because it should not have updated + assert.notCalled(MessageLoaderUtils._loadDataForProvider); + }); + it("should update local groups on pref change (no RS update)", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + userPreferences: ["cfrAddons"], + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + // Pref changed, updated the group state + assert.propertyVal(group, "enabled", false); + }); + }); + describe("unblockAll", () => { + it("Clears the message block list and returns the state value", async () => { + await Router.setState({ messageBlockList: ["one", "two", "three"] }); + assert.equal(Router.state.messageBlockList.length, 3); + const state = await Router.unblockAll(); + assert.equal(Router.state.messageBlockList.length, 0); + assert.equal(state.messageBlockList.length, 0); + }); + }); + describe("#loadMessagesForProvider", () => { + it("should fetch messages from the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["spotlight"], + }; + + await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.calledOnce(global.NimbusFeatures.spotlight.getAllVariables); + assert.calledOnce(global.ExperimentAPI.getExperimentMetaData); + assert.calledWithExactly(global.ExperimentAPI.getExperimentMetaData, { + featureId: "spotlight", + }); + }); + it("should handle the case of no experiments in the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + + global.ExperimentAPI.getExperiment.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should normally load ExperimentAPI messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + const enrollment = { + branch: { + slug: "branch01", + infobar: { + featureId: "infobar", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.infobar.getAllVariables.returns( + enrollment.branch.infobar.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.returns([ + enrollment.branch, + { + slug: "control", + infobar: { + featureId: "infobar", + value: null, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 1); + }); + it("should skip disabled features and not load the messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + + global.NimbusFeatures.cfr.getAllVariables.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should fetch branches with trigger", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].id, "id01"); + assert.equal(result.messages[1].id, "id02"); + assert.equal(result.messages[1].experimentSlug, "exp01"); + assert.equal(result.messages[1].branchSlug, "branch02"); + assert.deepEqual(result.messages[1].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + it("should fetch branches with trigger even if enrolled branch is disabled", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: {}, + }, + }, + }; + + // Nedds to match the `featureIds` value to return an enrollment + // for that feature + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].id, "id02"); + assert.equal(result.messages[0].experimentSlug, "exp01"); + assert.equal(result.messages[0].branchSlug, "branch02"); + assert.deepEqual(result.messages[0].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + }); + describe("#_remoteSettingsLoader", () => { + let provider; + let spy; + beforeEach(() => { + provider = { + id: "cfr", + collection: "cfr", + }; + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + }); + it("should be called with the expected dir path", async () => { + const dlSpy = sandbox.spy(global, "Downloader"); + + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledWith( + dlSpy, + "main", + "ms-language-packs", + "browser", + "newtab" + ); + }); + it("should allow fetch for known locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledOnce(spy); + }); + it("should fallback to 'en-US' for locale 'und' ", async () => { + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => "und"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("en-US")); + assert.calledOnce(spy); + }); + it("should fallback to 'ja-JP-mac' for locale 'ja-JP-macos'", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "ja-JP-macos"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("ja-JP-mac")); + assert.calledOnce(spy); + }); + it("should not allow fetch for unsupported locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "unkown"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.notCalled(spy); + }); + }); + describe("#resetMessageState", () => { + it("should reset all message impressions", async () => { + await Router.setState({ + messages: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test messages + let impressions = Object.values(Router.state.messageImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions + + Router.resetMessageState(); + impressions = Object.values(Router.state.messageImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both messages now have zero impressions + assert.calledWithExactly(Router._storage.set, "messageImpressions", { + 1: [], + 2: [], + }); + }); + }); + describe("#resetGroupsState", () => { + it("should reset all group impressions", async () => { + await Router.setState({ + groups: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + groupImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test groups + let impressions = Object.values(Router.state.groupImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both groups have impressions + + Router.resetGroupsState(); + impressions = Object.values(Router.state.groupImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both groups now have zero impressions + assert.calledWithExactly(Router._storage.set, "groupImpressions", { + 1: [], + 2: [], + }); + }); + }); + describe("#resetScreenImpressions", () => { + it("should reset all screen impressions", async () => { + await Router.setState({ screenImpressions: { 1: 1, 2: 2 } }); + let impressions = Object.values(Router.state.screenImpressions); + assert.equal(impressions.filter(i => i).length, 2); // Both screens have impressions + + Router.resetScreenImpressions(); + impressions = Object.values(Router.state.screenImpressions); + + assert.isEmpty(impressions.filter(i => i)); // Both screens now have zero impressions + assert.calledWithExactly(Router._storage.set, "screenImpressions", {}); + }); + }); + describe("#editState", () => { + it("should update message impressions", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + await Router.setState({ messages: [{ id: "1" }, { id: "2" }] }); + await Router.setState({ + messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); + let impressions = Object.values(Router.state.messageImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions + + Router.editState("messageImpressions", { + 1: [], + 2: [], + 3: [0, 1, 2], + }); + + // The original messages now have zero impressions + assert.isEmpty(Router.state.messageImpressions["1"]); + assert.isEmpty(Router.state.messageImpressions["2"]); + // A new impression array was added for the new message + assert.equal(Router.state.messageImpressions["3"].length, 3); + assert.calledWithExactly(Router._storage.set, "messageImpressions", { + 1: [], + 2: [], + 3: [0, 1, 2], + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterChild.test.js b/browser/components/asrouter/tests/unit/ASRouterChild.test.js new file mode 100644 index 0000000000..41fdd79ea2 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterChild.test.js @@ -0,0 +1,71 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterChild } from "actors/ASRouterChild.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterChild", () => { + let asRouterChild = null; + let globals = null; + let overrider = null; + let sandbox = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = { + Cu: { + cloneInto: sandbox.stub().returns(Promise.resolve()), + }, + }; + overrider = new GlobalOverrider(); + overrider.set(globals); + asRouterChild = new ASRouterChild(); + asRouterChild.telemetry = { + sendTelemetry: sandbox.stub(), + }; + sandbox.stub(asRouterChild, "sendAsyncMessage"); + sandbox.stub(asRouterChild, "sendQuery").returns(Promise.resolve()); + }); + afterEach(() => { + sandbox.restore(); + overrider.restore(); + asRouterChild = null; + }); + describe("asRouterMessage", () => { + describe("uses sendAsyncMessage for types that don't need an async response", () => { + [ + msg.DISABLE_PROVIDER, + msg.ENABLE_PROVIDER, + msg.EXPIRE_QUERY_CACHE, + msg.FORCE_WHATSNEW_PANEL, + msg.IMPRESSION, + msg.RESET_PROVIDER_PREF, + msg.SET_PROVIDER_USER_PREF, + msg.USER_ACTION, + ].forEach(type => { + it(`type ${type}`, () => { + asRouterChild.asRouterMessage({ + type, + data: { + something: 1, + }, + }); + sandbox.assert.calledOnce(asRouterChild.sendAsyncMessage); + sandbox.assert.calledWith(asRouterChild.sendAsyncMessage, type, { + something: 1, + }); + }); + }); + }); + // Some legacy privileged extensions still send this legacy NEWTAB_MESSAGE_REQUEST + // action type. We simply + it("can accept the legacy NEWTAB_MESSAGE_REQUEST message without throwing", async () => { + assert.doesNotThrow(async () => { + let result = await asRouterChild.asRouterMessage({ + type: "NEWTAB_MESSAGE_REQUEST", + data: {}, + }); + sandbox.assert.deepEqual(result, {}); + sandbox.assert.notCalled(asRouterChild.sendAsyncMessage); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js b/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js new file mode 100644 index 0000000000..664b685881 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterNewTabHook.test.js @@ -0,0 +1,153 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterNewTabHook } from "modules/ASRouterNewTabHook.sys.mjs"; + +describe("ASRouterNewTabHook", () => { + let sandbox = null; + let initParams = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + initParams = { + router: { + init: sandbox.stub().callsFake(() => { + // Fake the initialization + initParams.router.initialized = true; + }), + uninit: sandbox.stub(), + }, + messageHandler: { + handleCFRAction: {}, + handleTelemetry: {}, + }, + createStorage: () => Promise.resolve({}), + }; + }); + afterEach(() => { + sandbox.restore(); + }); + describe("ASRouterNewTabHook", () => { + describe("getInstance", () => { + it("awaits createInstance and router init before returning instance", async () => { + const getInstanceCall = sandbox.spy(); + const waitForInstance = + ASRouterNewTabHook.getInstance().then(getInstanceCall); + await ASRouterNewTabHook.createInstance(initParams); + await waitForInstance; + assert.callOrder(initParams.router.init, getInstanceCall); + }); + }); + describe("createInstance", () => { + it("calls router init", async () => { + await ASRouterNewTabHook.createInstance(initParams); + assert.calledOnce(initParams.router.init); + }); + it("only calls router init once", async () => { + initParams.router.init.callsFake(() => { + initParams.router.initialized = true; + }); + await ASRouterNewTabHook.createInstance(initParams); + await ASRouterNewTabHook.createInstance(initParams); + assert.calledOnce(initParams.router.init); + }); + }); + describe("destroy", () => { + it("disconnects new tab, uninits ASRouter, and destroys instance", async () => { + await ASRouterNewTabHook.createInstance(initParams); + const instance = await ASRouterNewTabHook.getInstance(); + const destroy = instance.destroy.bind(instance); + sandbox.stub(instance, "destroy").callsFake(destroy); + ASRouterNewTabHook.destroy(); + assert.calledOnce(initParams.router.uninit); + assert.calledOnce(instance.destroy); + assert.isNotNull(instance); + assert.isNull(instance._newTabMessageHandler); + }); + }); + describe("instance", () => { + let routerParams = null; + let messageHandler = null; + let instance = null; + beforeEach(async () => { + messageHandler = { + clearChildMessages: sandbox.stub().resolves(), + clearChildProviders: sandbox.stub().resolves(), + updateAdminState: sandbox.stub().resolves(), + }; + initParams.router.init.callsFake(params => { + routerParams = params; + }); + await ASRouterNewTabHook.createInstance(initParams); + instance = await ASRouterNewTabHook.getInstance(); + }); + describe("connect", () => { + it("before connection messageHandler methods are not called", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.notCalled(messageHandler.clearChildMessages); + assert.notCalled(messageHandler.clearChildProviders); + assert.notCalled(messageHandler.updateAdminState); + }); + it("after connect updateAdminState and clearChildMessages calls are forwarded to handler", async () => { + instance.connect(messageHandler); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.called(messageHandler.clearChildMessages); + assert.called(messageHandler.clearChildProviders); + assert.called(messageHandler.updateAdminState); + }); + it("calls from before connection are dropped", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + instance.connect(messageHandler); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.calledOnce(messageHandler.clearChildMessages); + assert.calledOnce(messageHandler.clearChildProviders); + assert.calledOnce(messageHandler.updateAdminState); + }); + }); + describe("disconnect", () => { + it("calls after disconnect are dropped", async () => { + instance.connect(messageHandler); + instance.disconnect(); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["test_provider"]); + routerParams.updateAdminState({ messages: {} }); + assert.notCalled(messageHandler.clearChildMessages); + assert.notCalled(messageHandler.clearChildProviders); + assert.notCalled(messageHandler.updateAdminState); + }); + it("only calls from when there is a connection are forwarded", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["foo"]); + routerParams.updateAdminState({ messages: {} }); + instance.connect(messageHandler); + routerParams.clearChildMessages([200]); + routerParams.clearChildProviders(["bar"]); + routerParams.updateAdminState({ + messages: { + data: "accept", + }, + }); + instance.disconnect(); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["foo"]); + routerParams.updateAdminState({ messages: {} }); + assert.calledOnce(messageHandler.clearChildMessages); + assert.calledOnce(messageHandler.clearChildProviders); + assert.calledOnce(messageHandler.updateAdminState); + assert.calledWith(messageHandler.clearChildMessages, [200]); + assert.calledWith(messageHandler.clearChildProviders, ["bar"]); + assert.calledWith(messageHandler.updateAdminState, { + messages: { + data: "accept", + }, + }); + }); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterParent.test.js b/browser/components/asrouter/tests/unit/ASRouterParent.test.js new file mode 100644 index 0000000000..0358b1261c --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterParent.test.js @@ -0,0 +1,83 @@ +import { ASRouterParent } from "actors/ASRouterParent.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; + +describe("ASRouterParent", () => { + let asRouterParent = null; + let sandbox = null; + let handleMessage = null; + let tabs = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + handleMessage = sandbox.stub().resolves("handle-message-result"); + ASRouterParent.nextTabId = 1; + const methods = { + destroy: sandbox.stub(), + size: 1, + messageAll: sandbox.stub().resolves(), + registerActor: sandbox.stub(), + unregisterActor: sandbox.stub(), + loadingMessageHandler: Promise.resolve({ + handleMessage, + }), + }; + tabs = { + methods, + factory: sandbox.stub().returns(methods), + }; + asRouterParent = new ASRouterParent({ tabsFactory: tabs.factory }); + ASRouterParent.tabs = tabs.methods; + asRouterParent.browsingContext = { + embedderElement: { + getAttribute: () => true, + }, + }; + asRouterParent.tabId = ASRouterParent.nextTabId; + }); + afterEach(() => { + sandbox.restore(); + asRouterParent = null; + }); + describe("actorCreated", () => { + it("after ASRouterTabs is instanced", () => { + asRouterParent.actorCreated(); + assert.equal(asRouterParent.tabId, 2); + assert.notCalled(tabs.factory); + assert.calledOnce(tabs.methods.registerActor); + }); + it("before ASRouterTabs is instanced", () => { + ASRouterParent.tabs = null; + ASRouterParent.nextTabId = 0; + asRouterParent.actorCreated(); + assert.calledOnce(tabs.factory); + assert.isNotNull(ASRouterParent.tabs); + assert.equal(asRouterParent.tabId, 1); + }); + }); + describe("didDestroy", () => { + it("one still remains", () => { + ASRouterParent.tabs.size = 1; + asRouterParent.didDestroy(); + assert.isNotNull(ASRouterParent.tabs); + assert.calledOnce(ASRouterParent.tabs.unregisterActor); + assert.notCalled(ASRouterParent.tabs.destroy); + }); + it("none remain", () => { + ASRouterParent.tabs.size = 0; + const tabsCopy = ASRouterParent.tabs; + asRouterParent.didDestroy(); + assert.isNull(ASRouterParent.tabs); + assert.calledOnce(tabsCopy.unregisterActor); + assert.calledOnce(tabsCopy.destroy); + }); + }); + describe("receiveMessage", async () => { + it("passes call to parentProcessMessageHandler and returns the result from handler", async () => { + const result = await asRouterParent.receiveMessage({ + name: msg.BLOCK_MESSAGE_BY_ID, + data: { id: 1 }, + }); + assert.calledOnce(handleMessage); + assert.equal(result, "handle-message-result"); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js new file mode 100644 index 0000000000..7bfec3e099 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterParentProcessMessageHandler.test.js @@ -0,0 +1,428 @@ +import { ASRouterParentProcessMessageHandler } from "modules/ASRouterParentProcessMessageHandler.sys.mjs"; +import { _ASRouter } from "modules/ASRouter.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs"; + +describe("ASRouterParentProcessMessageHandler", () => { + let handler = null; + let sandbox = null; + let config = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + const returnValue = { value: 1 }; + const router = new _ASRouter(); + [ + "addImpression", + "evaluateExpression", + "forceAttribution", + "forceWNPanel", + "closeWNPanel", + "forcePBWindow", + "resetGroupsState", + "resetMessageState", + "resetScreenImpressions", + "editState", + ].forEach(method => sandbox.stub(router, `${method}`).resolves()); + [ + "blockMessageById", + "loadMessagesFromAllProviders", + "sendTriggerMessage", + "routeCFRMessage", + "setMessageById", + "updateTargetingParameters", + "unblockMessageById", + "unblockAll", + ].forEach(method => + sandbox.stub(router, `${method}`).resolves(returnValue) + ); + router._storage = { + set: sandbox.stub().resolves(), + get: sandbox.stub().resolves(), + }; + sandbox.stub(router, "setState").callsFake(callback => { + if (typeof callback === "function") { + callback({ + messageBlockList: [ + { + id: 0, + }, + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + { + id: 4, + }, + ], + }); + } + return Promise.resolve(returnValue); + }); + const preferences = { + enableOrDisableProvider: sandbox.stub(), + resetProviderPref: sandbox.stub(), + setUserPreference: sandbox.stub(), + }; + const specialMessageActions = { + handleAction: sandbox.stub(), + }; + const queryCache = { + expireAll: sandbox.stub(), + }; + const sendTelemetry = sandbox.stub(); + config = { + router, + preferences, + specialMessageActions, + queryCache, + sendTelemetry, + }; + handler = new ASRouterParentProcessMessageHandler(config); + }); + afterEach(() => { + sandbox.restore(); + handler = null; + config = null; + }); + describe("constructor", () => { + it("does not throw", () => { + assert.isNotNull(handler); + assert.isNotNull(config); + }); + }); + describe("handleCFRAction", () => { + it("non-telemetry type isn't sent to telemetry", () => { + handler.handleCFRAction({ + type: msg.BLOCK_MESSAGE_BY_ID, + data: { id: 1 }, + }); + assert.notCalled(config.sendTelemetry); + assert.calledOnce(config.router.blockMessageById); + }); + it("passes browser to handleMessage", async () => { + await handler.handleCFRAction( + { + type: msg.USER_ACTION, + data: { id: 1 }, + }, + { ownerGlobal: {} } + ); + assert.notCalled(config.sendTelemetry); + assert.calledOnce(config.specialMessageActions.handleAction); + assert.calledWith( + config.specialMessageActions.handleAction, + { id: 1 }, + { ownerGlobal: {} } + ); + }); + [ + msg.AS_ROUTER_TELEMETRY_USER_EVENT, + msg.TOOLBAR_BADGE_TELEMETRY, + msg.TOOLBAR_PANEL_TELEMETRY, + msg.MOMENTS_PAGE_TELEMETRY, + msg.DOORHANGER_TELEMETRY, + ].forEach(type => { + it(`telemetry type "${type}" is sent to telemetry`, () => { + handler.handleCFRAction({ + type, + data: { id: 1 }, + }); + assert.calledOnce(config.sendTelemetry); + assert.notCalled(config.router.blockMessageById); + }); + }); + }); + describe("#handleMessage", () => { + it("#default: should throw for unknown msg types", () => { + handler.handleMessage("err").then( + () => assert.fail("It should not succeed"), + () => assert.ok(true) + ); + }); + describe("#AS_ROUTER_TELEMETRY_USER_EVENT", () => { + it("should route AS_ROUTER_TELEMETRY_USER_EVENT to handleTelemetry", async () => { + const data = { data: "foo" }; + await handler.handleMessage(msg.AS_ROUTER_TELEMETRY_USER_EVENT, data); + + assert.calledOnce(handler.handleTelemetry); + assert.calledWithExactly(handler.handleTelemetry, { + type: msg.AS_ROUTER_TELEMETRY_USER_EVENT, + data, + }); + }); + }); + describe("BLOCK_MESSAGE_BY_ID action", () => { + it("with preventDismiss returns false", async () => { + const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, { + id: 1, + preventDismiss: true, + }); + assert.calledOnce(config.router.blockMessageById); + assert.isFalse(result); + }); + it("by default returns true", async () => { + const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, { + id: 1, + }); + assert.calledOnce(config.router.blockMessageById); + assert.isTrue(result); + }); + }); + describe("USER_ACTION action", () => { + it("default calls SpecialMessageActions.handleAction", async () => { + await handler.handleMessage( + msg.USER_ACTION, + { + type: "SOMETHING", + }, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.specialMessageActions.handleAction); + assert.calledWith( + config.specialMessageActions.handleAction, + { type: "SOMETHING" }, + { ownerGlobal: {} } + ); + }); + }); + describe("IMPRESSION action", () => { + it("default calls addImpression", () => { + handler.handleMessage(msg.IMPRESSION, { + id: 1, + }); + assert.calledOnce(config.router.addImpression); + }); + }); + describe("TRIGGER action", () => { + it("default calls sendTriggerMessage and returns state", async () => { + const result = await handler.handleMessage( + msg.TRIGGER, + { + trigger: { stuff: {} }, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.sendTriggerMessage); + assert.calledWith(config.router.sendTriggerMessage, { + stuff: {}, + tabId: 100, + browser: { ownerGlobal: {} }, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("ADMIN_CONNECT_STATE action", () => { + it("with endpoint url calls loadMessagesFromAllProviders, and returns state", async () => { + const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE, { + endpoint: { + url: "test", + }, + }); + assert.calledOnce(config.router.loadMessagesFromAllProviders); + assert.deepEqual(result, { value: 1 }); + }); + it("default returns state", async () => { + const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE); + assert.calledOnce(config.router.updateTargetingParameters); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_MESSAGE_BY_ID action", () => { + it("default calls unblockMessageById", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_MESSAGE_BY_ID, { + id: 1, + }); + assert.calledOnce(config.router.unblockMessageById); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_ALL action", () => { + it("default calls unblockAll", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_ALL); + assert.calledOnce(config.router.unblockAll); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("BLOCK_BUNDLE action", () => { + it("default calls unblockMessageById", async () => { + const result = await handler.handleMessage(msg.BLOCK_BUNDLE, { + bundle: [ + { + id: 8, + }, + { + id: 13, + }, + ], + }); + assert.calledOnce(config.router.blockMessageById); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_BUNDLE action", () => { + it("default calls setState", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_BUNDLE, { + bundle: [ + { + id: 1, + }, + { + id: 3, + }, + ], + }); + assert.calledOnce(config.router.setState); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("DISABLE_PROVIDER action", () => { + it("default calls ASRouterPreferences.enableOrDisableProvider", () => { + handler.handleMessage(msg.DISABLE_PROVIDER, {}); + assert.calledOnce(config.preferences.enableOrDisableProvider); + }); + }); + describe("ENABLE_PROVIDER action", () => { + it("default calls ASRouterPreferences.enableOrDisableProvider", () => { + handler.handleMessage(msg.ENABLE_PROVIDER, {}); + assert.calledOnce(config.preferences.enableOrDisableProvider); + }); + }); + describe("EVALUATE_JEXL_EXPRESSION action", () => { + it("default calls evaluateExpression", () => { + handler.handleMessage(msg.EVALUATE_JEXL_EXPRESSION, {}); + assert.calledOnce(config.router.evaluateExpression); + }); + }); + describe("EXPIRE_QUERY_CACHE action", () => { + it("default calls QueryCache.expireAll", () => { + handler.handleMessage(msg.EXPIRE_QUERY_CACHE); + assert.calledOnce(config.queryCache.expireAll); + }); + }); + describe("FORCE_ATTRIBUTION action", () => { + it("default calls forceAttribution", () => { + handler.handleMessage(msg.FORCE_ATTRIBUTION, {}); + assert.calledOnce(config.router.forceAttribution); + }); + }); + describe("FORCE_WHATSNEW_PANEL action", () => { + it("default calls forceWNPanel", () => { + handler.handleMessage( + msg.FORCE_WHATSNEW_PANEL, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.forceWNPanel); + assert.calledWith(config.router.forceWNPanel, { ownerGlobal: {} }); + }); + }); + describe("CLOSE_WHATSNEW_PANEL action", () => { + it("default calls closeWNPanel", () => { + handler.handleMessage( + msg.CLOSE_WHATSNEW_PANEL, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.closeWNPanel); + assert.calledWith(config.router.closeWNPanel, { ownerGlobal: {} }); + }); + }); + describe("FORCE_PRIVATE_BROWSING_WINDOW action", () => { + it("default calls forcePBWindow", () => { + handler.handleMessage( + msg.FORCE_PRIVATE_BROWSING_WINDOW, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.forcePBWindow); + assert.calledWith(config.router.forcePBWindow, { ownerGlobal: {} }); + }); + }); + describe("MODIFY_MESSAGE_JSON action", () => { + it("default calls routeCFRMessage", async () => { + const result = await handler.handleMessage( + msg.MODIFY_MESSAGE_JSON, + { + content: { + text: "something", + }, + }, + { browser: { ownerGlobal: {} }, id: 100 } + ); + assert.calledOnce(config.router.routeCFRMessage); + assert.calledWith( + config.router.routeCFRMessage, + { text: "something" }, + { ownerGlobal: {} }, + { content: { text: "something" } }, + true + ); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("OVERRIDE_MESSAGE action", () => { + it("default calls setMessageById", async () => { + const result = await handler.handleMessage( + msg.OVERRIDE_MESSAGE, + { + id: 1, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.setMessageById); + assert.calledWith(config.router.setMessageById, { id: 1 }, true, { + ownerGlobal: {}, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("RESET_PROVIDER_PREF action", () => { + it("default calls ASRouterPreferences.resetProviderPref", () => { + handler.handleMessage(msg.RESET_PROVIDER_PREF); + assert.calledOnce(config.preferences.resetProviderPref); + }); + }); + describe("SET_PROVIDER_USER_PREF action", () => { + it("default calls ASRouterPreferences.setUserPreference", () => { + handler.handleMessage(msg.SET_PROVIDER_USER_PREF, { + id: 1, + value: true, + }); + assert.calledOnce(config.preferences.setUserPreference); + assert.calledWith(config.preferences.setUserPreference, 1, true); + }); + }); + describe("RESET_GROUPS_STATE action", () => { + it("default calls resetGroupsState, loadMessagesFromAllProviders, and returns state", async () => { + const result = await handler.handleMessage(msg.RESET_GROUPS_STATE, { + property: "value", + }); + assert.calledOnce(config.router.resetGroupsState); + assert.calledOnce(config.router.loadMessagesFromAllProviders); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("RESET_MESSAGE_STATE action", () => { + it("default calls resetMessageState", () => { + handler.handleMessage(msg.RESET_MESSAGE_STATE); + assert.calledOnce(config.router.resetMessageState); + }); + }); + describe("RESET_SCREEN_IMPRESSIONS action", () => { + it("default calls resetScreenImpressions", () => { + handler.handleMessage(msg.RESET_SCREEN_IMPRESSIONS); + assert.calledOnce(config.router.resetScreenImpressions); + }); + }); + describe("EDIT_STATE action", () => { + it("default calls editState with correct args", () => { + handler.handleMessage(msg.EDIT_STATE, { property: "value" }); + assert.calledWith(config.router.editState, "property", "value"); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js b/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js new file mode 100644 index 0000000000..a3fe1fc5c9 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterPreferences.test.js @@ -0,0 +1,480 @@ +import { + _ASRouterPreferences, + ASRouterPreferences as ASRouterPreferencesSingleton, + TEST_PROVIDERS, +} from "modules/ASRouterPreferences.sys.mjs"; +const FAKE_PROVIDERS = [{ id: "foo" }, { id: "bar" }]; + +const PROVIDER_PREF_BRANCH = + "browser.newtabpage.activity-stream.asrouter.providers."; +const DEVTOOLS_PREF = + "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled"; +const CFR_USER_PREF_ADDONS = + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons"; +const CFR_USER_PREF_FEATURES = + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features"; + +/** NUMBER_OF_PREFS_TO_OBSERVE includes: + * 1. asrouter.providers. pref branch + * 2. asrouter.devtoolsEnabled + * 3. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons (user preference - cfr) + * 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features (user preference - cfr) + * 5. services.sync.username + */ +const NUMBER_OF_PREFS_TO_OBSERVE = 5; + +describe("ASRouterPreferences", () => { + let ASRouterPreferences; + let sandbox; + let addObserverStub; + let stringPrefStub; + let boolPrefStub; + let resetStub; + let hasUserValueStub; + let childListStub; + let setStringPrefStub; + + beforeEach(() => { + ASRouterPreferences = new _ASRouterPreferences(); + + sandbox = sinon.createSandbox(); + addObserverStub = sandbox.stub(global.Services.prefs, "addObserver"); + stringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + resetStub = sandbox.stub(global.Services.prefs, "clearUserPref"); + setStringPrefStub = sandbox.stub(global.Services.prefs, "setStringPref"); + FAKE_PROVIDERS.forEach(provider => { + stringPrefStub + .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`) + .returns(JSON.stringify(provider)); + }); + + boolPrefStub = sandbox + .stub(global.Services.prefs, "getBoolPref") + .returns(false); + + hasUserValueStub = sandbox + .stub(global.Services.prefs, "prefHasUserValue") + .returns(false); + + childListStub = sandbox.stub(global.Services.prefs, "getChildList"); + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + FAKE_PROVIDERS.map(provider => `${PROVIDER_PREF_BRANCH}${provider.id}`) + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function getPrefNameForProvider(providerId) { + return `${PROVIDER_PREF_BRANCH}${providerId}`; + } + + function setPrefForProvider(providerId, value) { + stringPrefStub + .withArgs(getPrefNameForProvider(providerId)) + .returns(JSON.stringify(value)); + } + + it("ASRouterPreferences should be an instance of _ASRouterPreferences", () => { + assert.instanceOf(ASRouterPreferencesSingleton, _ASRouterPreferences); + }); + describe("#init", () => { + it("should set ._initialized to true", () => { + ASRouterPreferences.init(); + assert.isTrue(ASRouterPreferences._initialized); + }); + it("should migrate the provider prefs", () => { + ASRouterPreferences.uninit(); + // Should be migrated because they contain bucket and not collection + const MIGRATE_PROVIDERS = [ + { id: "baz", bucket: "buk" }, + { id: "qux", bucket: "buk" }, + ]; + // Should be cleared to defaults because it throws on setStringPref + const ERROR_PROVIDER = { id: "err", bucket: "buk" }; + // Should not be migrated because, although modified, it lacks bucket + const MODIFIED_SAFE_PROVIDER = { id: "safe" }; + const ALL_PROVIDERS = [ + ...MIGRATE_PROVIDERS, + ...FAKE_PROVIDERS, // Should not be migrated because they're unmodified + MODIFIED_SAFE_PROVIDER, + ERROR_PROVIDER, + ]; + // The migrator should attempt to read prefs for all of these providers + const TRY_PROVIDERS = [ + ...MIGRATE_PROVIDERS, + MODIFIED_SAFE_PROVIDER, + ERROR_PROVIDER, + ]; + + // Update the full list of provider prefs + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + ALL_PROVIDERS.map(provider => getPrefNameForProvider(provider.id)) + ); + // Stub the pref values so the migrator can read them + ALL_PROVIDERS.forEach(provider => { + stringPrefStub + .withArgs(getPrefNameForProvider(provider.id)) + .returns(JSON.stringify(provider)); + }); + + // Consider these providers' prefs "modified" + TRY_PROVIDERS.forEach(provider => { + hasUserValueStub + .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`) + .returns(true); + }); + // Spoof an error when trying to set the pref for this provider so we can + // test that the pref is gracefully reset on error + setStringPrefStub + .withArgs(getPrefNameForProvider(ERROR_PROVIDER.id)) + .throws(); + + ASRouterPreferences.init(); + + // The migrator should have tried to check each pref for user modification + ALL_PROVIDERS.forEach(provider => + assert.calledWith(hasUserValueStub, getPrefNameForProvider(provider.id)) + ); + // Test that we don't call getStringPref for providers that don't have a + // user-defined value + FAKE_PROVIDERS.forEach(provider => + assert.neverCalledWith( + stringPrefStub, + getPrefNameForProvider(provider.id) + ) + ); + // But we do call it for providers that do have a user-defined value + TRY_PROVIDERS.forEach(provider => + assert.calledWith(stringPrefStub, getPrefNameForProvider(provider.id)) + ); + + // Test that we don't call setStringPref to migrate providers that don't + // have a bucket property + assert.neverCalledWith( + setStringPrefStub, + getPrefNameForProvider(MODIFIED_SAFE_PROVIDER.id) + ); + + /** + * For a given provider, return a sinon matcher that matches if the value + * looks like a migrated version of the original provider. Requires that: + * its id matches the original provider's id; it has no bucket; and its + * collection is set to the value of the original provider's bucket. + * @param {object} provider the provider object to compare to + * @returns {object} custom matcher object for sinon + */ + function providerJsonMatches(provider) { + return sandbox.match(migrated => { + const parsed = JSON.parse(migrated); + return ( + parsed.id === provider.id && + !("bucket" in parsed) && + parsed.collection === provider.bucket + ); + }); + } + + // Test that we call setStringPref to migrate providers that have a bucket + // property and don't have a collection property + MIGRATE_PROVIDERS.forEach(provider => + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider(provider.id), + providerJsonMatches(provider) // Verify the migrated pref value + ) + ); + + // Test that we clear the pref for providers that throw when we try to + // read or write them + assert.calledWith(resetStub, getPrefNameForProvider(ERROR_PROVIDER.id)); + }); + it(`should set ${NUMBER_OF_PREFS_TO_OBSERVE} observers and not re-initialize if already initialized`, () => { + ASRouterPreferences.init(); + assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE); + ASRouterPreferences.init(); + ASRouterPreferences.init(); + assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE); + }); + }); + describe("#uninit", () => { + it("should set ._initialized to false", () => { + ASRouterPreferences.init(); + ASRouterPreferences.uninit(); + assert.isFalse(ASRouterPreferences._initialized); + }); + it("should clear cached values for ._initialized, .devtoolsEnabled", () => { + ASRouterPreferences.init(); + // trigger caching + // eslint-disable-next-line no-unused-vars + const result = [ + ASRouterPreferences.providers, + ASRouterPreferences.devtoolsEnabled, + ]; + assert.isNotNull( + ASRouterPreferences._providers, + "providers should not be null" + ); + assert.isNotNull( + ASRouterPreferences._devtoolsEnabled, + "devtolosEnabled should not be null" + ); + + ASRouterPreferences.uninit(); + assert.isNull(ASRouterPreferences._providers); + assert.isNull(ASRouterPreferences._devtoolsEnabled); + }); + it("should clear all listeners and remove observers (only once)", () => { + const removeStub = sandbox.stub(global.Services.prefs, "removeObserver"); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(() => {}); + ASRouterPreferences.addListener(() => {}); + assert.equal(ASRouterPreferences._callbacks.size, 2); + ASRouterPreferences.uninit(); + // Tests to make sure we don't remove observers that weren't set + ASRouterPreferences.uninit(); + + assert.callCount(removeStub, NUMBER_OF_PREFS_TO_OBSERVE); + assert.calledWith(removeStub, PROVIDER_PREF_BRANCH); + assert.calledWith(removeStub, DEVTOOLS_PREF); + assert.isEmpty(ASRouterPreferences._callbacks); + }); + }); + describe(".providers", () => { + it("should return the value the first time .providers is accessed", () => { + ASRouterPreferences.init(); + + const result = ASRouterPreferences.providers; + assert.deepEqual(result, FAKE_PROVIDERS); + // once per pref + assert.calledTwice(stringPrefStub); + }); + it("should return the cached value the second time .providers is accessed", () => { + ASRouterPreferences.init(); + const [, secondCall] = [ + ASRouterPreferences.providers, + ASRouterPreferences.providers, + ]; + + assert.deepEqual(secondCall, FAKE_PROVIDERS); + // once per pref + assert.calledTwice(stringPrefStub); + }); + it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => { + // Intentionally not initialized + const [firstCall, secondCall] = [ + ASRouterPreferences.providers, + ASRouterPreferences.providers, + ]; + + assert.deepEqual(firstCall, FAKE_PROVIDERS); + assert.deepEqual(secondCall, FAKE_PROVIDERS); + assert.callCount(stringPrefStub, 4); + }); + it("should skip the pref without throwing if a pref is not parsable", () => { + stringPrefStub.withArgs(`${PROVIDER_PREF_BRANCH}foo`).returns("not json"); + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, [{ id: "bar" }]); + }); + it("should include TEST_PROVIDERS if devtools is turned on", () => { + boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true); + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, [ + ...TEST_PROVIDERS, + ...FAKE_PROVIDERS, + ]); + }); + }); + describe(".devtoolsEnabled", () => { + it("should read the pref the first time .devtoolsEnabled is accessed", () => { + ASRouterPreferences.init(); + + const result = ASRouterPreferences.devtoolsEnabled; + assert.deepEqual(result, false); + assert.calledOnce(boolPrefStub); + }); + it("should return the cached value the second time .devtoolsEnabled is accessed", () => { + ASRouterPreferences.init(); + const [, secondCall] = [ + ASRouterPreferences.devtoolsEnabled, + ASRouterPreferences.devtoolsEnabled, + ]; + + assert.deepEqual(secondCall, false); + assert.calledOnce(boolPrefStub); + }); + it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => { + // Intentionally not initialized + const [firstCall, secondCall] = [ + ASRouterPreferences.devtoolsEnabled, + ASRouterPreferences.devtoolsEnabled, + ]; + + assert.deepEqual(firstCall, false); + assert.deepEqual(secondCall, false); + assert.calledTwice(boolPrefStub); + }); + }); + describe("#getAllUserPreferences", () => { + it("should return all user preferences", () => { + boolPrefStub.withArgs(CFR_USER_PREF_ADDONS).returns(false); + boolPrefStub.withArgs(CFR_USER_PREF_FEATURES).returns(true); + const result = ASRouterPreferences.getAllUserPreferences(); + assert.deepEqual(result, { + cfrAddons: false, + cfrFeatures: true, + }); + }); + }); + describe("#enableOrDisableProvider", () => { + it("should enable an existing provider if second param is true", () => { + setPrefForProvider("foo", { id: "foo", enabled: false }); + assert.isFalse(ASRouterPreferences.providers[0].enabled); + + ASRouterPreferences.enableOrDisableProvider("foo", true); + + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider("foo"), + JSON.stringify({ id: "foo", enabled: true }) + ); + }); + it("should disable an existing provider if second param is false", () => { + setPrefForProvider("foo", { id: "foo", enabled: true }); + assert.isTrue(ASRouterPreferences.providers[0].enabled); + + ASRouterPreferences.enableOrDisableProvider("foo", false); + + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider("foo"), + JSON.stringify({ id: "foo", enabled: false }) + ); + }); + it("should not throw if the id does not exist", () => { + assert.doesNotThrow(() => { + ASRouterPreferences.enableOrDisableProvider("does_not_exist", true); + }); + }); + it("should not throw if pref is not parseable", () => { + stringPrefStub + .withArgs(getPrefNameForProvider("foo")) + .returns("not valid"); + assert.doesNotThrow(() => { + ASRouterPreferences.enableOrDisableProvider("foo", true); + }); + }); + }); + describe("#setUserPreference", () => { + it("should do nothing if the pref doesn't exist", () => { + ASRouterPreferences.setUserPreference("foo", true); + assert.notCalled(boolPrefStub); + }); + it("should set the given pref", () => { + const setStub = sandbox.stub(global.Services.prefs, "setBoolPref"); + ASRouterPreferences.setUserPreference("cfrAddons", true); + assert.calledWith(setStub, CFR_USER_PREF_ADDONS, true); + }); + }); + describe("#resetProviderPref", () => { + it("should reset the pref and user prefs", () => { + ASRouterPreferences.resetProviderPref(); + FAKE_PROVIDERS.forEach(provider => { + assert.calledWith(resetStub, getPrefNameForProvider(provider.id)); + }); + assert.calledWith(resetStub, CFR_USER_PREF_ADDONS); + assert.calledWith(resetStub, CFR_USER_PREF_FEATURES); + }); + }); + describe("observer, listeners", () => { + it("should invalidate .providers when the pref is changed", () => { + const testProvider = { id: "newstuff" }; + const newProviders = [...FAKE_PROVIDERS, testProvider]; + + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, FAKE_PROVIDERS); + stringPrefStub + .withArgs(getPrefNameForProvider(testProvider.id)) + .returns(JSON.stringify(testProvider)); + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + newProviders.map(provider => getPrefNameForProvider(provider.id)) + ); + ASRouterPreferences.observe( + null, + null, + getPrefNameForProvider(testProvider.id) + ); + + // Cache should be invalidated so we access the new value of the pref now + assert.deepEqual(ASRouterPreferences.providers, newProviders); + }); + it("should invalidate .devtoolsEnabled and .providers when the pref is changed", () => { + ASRouterPreferences.init(); + + assert.isFalse(ASRouterPreferences.devtoolsEnabled); + boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true); + childListStub.withArgs(PROVIDER_PREF_BRANCH).returns([]); + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + + // Cache should be invalidated so we access the new value of the pref now + // Note that providers needs to be invalidated because devtools adds test content to it. + assert.isTrue(ASRouterPreferences.devtoolsEnabled); + assert.deepEqual(ASRouterPreferences.providers, TEST_PROVIDERS); + }); + it("should call listeners added with .addListener", () => { + const callback1 = sinon.stub(); + const callback2 = sinon.stub(); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(callback1); + ASRouterPreferences.addListener(callback2); + + ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo")); + assert.calledWith(callback1, getPrefNameForProvider("foo")); + + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + assert.calledWith(callback2, DEVTOOLS_PREF); + }); + it("should not call listeners after they are removed with .removeListeners", () => { + const callback = sinon.stub(); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(callback); + + ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo")); + assert.calledWith(callback, getPrefNameForProvider("foo")); + + callback.reset(); + ASRouterPreferences.removeListener(callback); + + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + assert.notCalled(callback); + }); + }); + describe("#_transformPersonalizedCfrScores", () => { + it("should report JSON.parse errors", () => { + sandbox.stub(global.console, "error"); + + ASRouterPreferences._transformPersonalizedCfrScores(""); + + assert.calledOnce(global.console.error); + }); + it("should return an object parsed from a string", () => { + const scores = { FOO: 3000, BAR: 4000 }; + assert.deepEqual( + ASRouterPreferences._transformPersonalizedCfrScores( + JSON.stringify(scores) + ), + scores + ); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js b/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js new file mode 100644 index 0000000000..610b488b47 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterTargeting.test.js @@ -0,0 +1,574 @@ +import { + ASRouterTargeting, + CachedTargetingGetter, + getSortedMessages, + QueryCache, +} from "modules/ASRouterTargeting.sys.mjs"; +import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs"; +import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +// Note that tests for the ASRouterTargeting environment can be found in +// test/functional/mochitest/browser_asrouter_targeting.js + +describe("#CachedTargetingGetter", () => { + const sixHours = 6 * 60 * 60 * 1000; + let sandbox; + let clock; + let frecentStub; + let topsitesCache; + let globals; + let doesAppNeedPinStub; + let getAddonsByTypesStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + frecentStub = sandbox.stub( + global.NewTabUtils.activityStreamProvider, + "getTopFrecentSites" + ); + topsitesCache = new CachedTargetingGetter("getTopFrecentSites"); + globals = new GlobalOverrider(); + globals.set( + "TargetingContext", + class { + static combineContexts(...args) { + return sinon.stub(); + } + + evalWithDefault(expr) { + return sinon.stub(); + } + } + ); + doesAppNeedPinStub = sandbox.stub().resolves(); + getAddonsByTypesStub = sandbox.stub().resolves(); + }); + + afterEach(() => { + sandbox.restore(); + clock.restore(); + globals.restore(); + }); + + it("should cache allow for optional getter argument", async () => { + let pinCachedGetter = new CachedTargetingGetter( + "doesAppNeedPin", + true, + undefined, + { doesAppNeedPin: doesAppNeedPinStub } + ); + // Need to tick forward because Date.now() is stubbed + clock.tick(sixHours); + + await pinCachedGetter.get(); + await pinCachedGetter.get(); + await pinCachedGetter.get(); + + // Called once; cached request + assert.calledOnce(doesAppNeedPinStub); + + // Called with option argument + assert.calledWith(doesAppNeedPinStub, true); + + // Expire and call again + clock.tick(sixHours); + await pinCachedGetter.get(); + + // Call goes through + assert.calledTwice(doesAppNeedPinStub); + + let themesCachedGetter = new CachedTargetingGetter( + "getAddonsByTypes", + ["foo"], + undefined, + { getAddonsByTypes: getAddonsByTypesStub } + ); + + // Need to tick forward because Date.now() is stubbed + clock.tick(sixHours); + + await themesCachedGetter.get(); + await themesCachedGetter.get(); + await themesCachedGetter.get(); + + // Called once; cached request + assert.calledOnce(getAddonsByTypesStub); + + // Called with option argument + assert.calledWith(getAddonsByTypesStub, ["foo"]); + + // Expire and call again + clock.tick(sixHours); + await themesCachedGetter.get(); + + // Call goes through + assert.calledTwice(getAddonsByTypesStub); + }); + + it("should only make a request every 6 hours", async () => { + frecentStub.resolves(); + clock.tick(sixHours); + + await topsitesCache.get(); + await topsitesCache.get(); + + assert.calledOnce( + global.NewTabUtils.activityStreamProvider.getTopFrecentSites + ); + + clock.tick(sixHours); + + await topsitesCache.get(); + + assert.calledTwice( + global.NewTabUtils.activityStreamProvider.getTopFrecentSites + ); + }); + it("throws when failing getter", async () => { + frecentStub.rejects(new Error("fake error")); + clock.tick(sixHours); + + // assert.throws expect a function as the first parameter, try/catch is a + // workaround + let rejected = false; + try { + await topsitesCache.get(); + } catch (e) { + rejected = true; + } + + assert(rejected); + }); + describe("sortMessagesByPriority", () => { + it("should sort messages in descending priority order", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 0 }, + { ...m2, priority: 1 }, + { ...m3, priority: 2 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m3.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m2.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m1.id); + }); + it("should sort messages with no priority last", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 0 }, + { ...m2, priority: undefined }, + { ...m3, priority: 2 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m3.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m1.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m2.id); + }); + it("should keep the order of messages with same priority unchanged", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 2, targeting: undefined, rank: 1 }, + { ...m2, priority: undefined, targeting: undefined, rank: 1 }, + { ...m3, priority: 2, targeting: undefined, rank: 1 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m1.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m3.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m2.id); + }); + }); +}); +describe("#isTriggerMatch", () => { + let trigger; + let message; + beforeEach(() => { + trigger = { id: "openURL" }; + message = { id: "openURL" }; + }); + it("should return false if trigger and candidate ids are different", () => { + trigger.id = "trigger"; + message.id = "message"; + + assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); + assert.isTrue( + ASRouterTargeting.isTriggerMatch({ id: "foo" }, { id: "foo" }) + ); + }); + it("should return true if the message we check doesn't have trigger params or patterns", () => { + // No params or patterns defined + assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return false if the trigger does not have params defined", () => { + message.params = {}; + + // trigger.param is undefined + assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return true if message params includes trigger host", () => { + message.params = ["mozilla.org"]; + trigger.param = { host: "mozilla.org" }; + + assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return true if message params includes trigger param.type", () => { + message.params = ["ContentBlockingMilestone"]; + trigger.param = { type: "ContentBlockingMilestone" }; + + assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); + }); + it("should return true if message params match trigger mask", () => { + // STATE_BLOCKED_FINGERPRINTING_CONTENT + message.params = [0x00000040]; + trigger.param = { type: 538091584 }; + + assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); + }); +}); +describe("#CacheListAttachedOAuthClients", () => { + const fourHours = 4 * 60 * 60 * 1000; + let sandbox; + let clock; + let fakeFxAccount; + let authClientsCache; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + fakeFxAccount = { + listAttachedOAuthClients: () => {}, + }; + globals.set("fxAccounts", fakeFxAccount); + authClientsCache = QueryCache.queries.ListAttachedOAuthClients; + sandbox + .stub(global.fxAccounts, "listAttachedOAuthClients") + .returns(Promise.resolve({})); + }); + + afterEach(() => { + authClientsCache.expire(); + sandbox.restore(); + clock.restore(); + }); + + it("should only make additional request every 4 hours", async () => { + clock.tick(fourHours); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + + clock.tick(fourHours); + await authClientsCache.get(); + assert.calledTwice(global.fxAccounts.listAttachedOAuthClients); + }); + + it("should not make additional request before 4 hours", async () => { + clock.tick(fourHours); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + }); +}); +describe("ASRouterTargeting", () => { + let evalStub; + let sandbox; + let clock; + let globals; + let fakeTargetingContext; + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.replace(ASRouterTargeting, "Environment", {}); + clock = sinon.useFakeTimers(); + fakeTargetingContext = { + combineContexts: sandbox.stub(), + evalWithDefault: sandbox.stub().resolves(), + setTelemetrySource: sandbox.stub(), + }; + globals = new GlobalOverrider(); + globals.set( + "TargetingContext", + class { + static combineContexts(...args) { + return fakeTargetingContext.combineContexts.apply(sandbox, args); + } + + setTelemetrySource(id) { + fakeTargetingContext.setTelemetrySource(id); + } + + evalWithDefault(expr) { + return fakeTargetingContext.evalWithDefault(expr); + } + } + ); + evalStub = fakeTargetingContext.evalWithDefault; + }); + afterEach(() => { + clock.restore(); + sandbox.restore(); + globals.restore(); + }); + it("should provide message.id as source", async () => { + await ASRouterTargeting.checkMessageTargeting( + { + id: "message", + targeting: "true", + }, + fakeTargetingContext, + sandbox.stub(), + false + ); + assert.calledOnce(fakeTargetingContext.evalWithDefault); + assert.calledWithExactly(fakeTargetingContext.evalWithDefault, "true"); + assert.calledWithExactly( + fakeTargetingContext.setTelemetrySource, + "message" + ); + }); + it("should cache evaluation result", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl1" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl2" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl1" }, + targetingContext, + sandbox.stub(), + true + ); + + assert.calledTwice(evalStub); + }); + it("should not cache evaluation result", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + + assert.calledThrice(evalStub); + }); + it("should expire cache entries", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + clock.tick(5 * 60 * 1000 + 1); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + + assert.calledTwice(evalStub); + }); + + describe("#findMatchingMessage", () => { + let matchStub; + let messages = [ + { id: "FOO", targeting: "match" }, + { id: "BAR", targeting: "match" }, + { id: "BAZ" }, + ]; + beforeEach(() => { + matchStub = sandbox + .stub(ASRouterTargeting, "_isMessageMatch") + .callsFake(message => message.targeting === "match"); + }); + it("should return an array of matches if returnAll is true", async () => { + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + returnAll: true, + }), + [ + { id: "FOO", targeting: "match" }, + { id: "BAR", targeting: "match" }, + ] + ); + }); + it("should return an empty array if no matches were found and returnAll is true", async () => { + matchStub.returns(false); + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + returnAll: true, + }), + [] + ); + }); + it("should return the first match if returnAll is false", async () => { + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + }), + messages[0] + ); + }); + it("should return null if if no matches were found and returnAll is false", async () => { + matchStub.returns(false); + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + }), + null + ); + }); + }); +}); + +/** + * Messages should be sorted in the following order: + * 1. Rank + * 2. Priority + * 3. If the message has targeting + * 4. Order or randomization, depending on input + */ +describe("getSortedMessages", () => { + let globals = new GlobalOverrider(); + let sandbox; + beforeEach(() => { + globals.set({ ASRouterPreferences }); + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + /** + * assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages, + * returns the items in the expected order. + * + * @param {Message[]} expectedOrderArray - The array of messages in its expected order + * @param {{}} options - The options param for getSortedMessages + * @returns + */ + function assertSortsCorrectly(expectedOrderArray, options) { + const input = [...expectedOrderArray].reverse(); + const result = getSortedMessages(input, options); + const indexes = result.map(message => expectedOrderArray.indexOf(message)); + return assert.equal( + indexes.join(","), + [...expectedOrderArray.keys()].join(","), + "Messsages are out of order" + ); + } + + it("should sort messages by priority, then by targeting", () => { + assertSortsCorrectly([ + { priority: 100, targeting: "isFoo" }, + { priority: 100 }, + { priority: 99 }, + { priority: 1, targeting: "isFoo" }, + { priority: 1 }, + {}, + ]); + }); + it("should sort messages by priority, then targeting, then order if ordered param is true", () => { + assertSortsCorrectly( + [ + { priority: 100, order: 4 }, + { priority: 100, order: 5 }, + { priority: 1, order: 3, targeting: "isFoo" }, + { priority: 1, order: 0 }, + { priority: 1, order: 1 }, + { priority: 1, order: 2 }, + { order: 0 }, + ], + { ordered: true } + ); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js b/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js new file mode 100644 index 0000000000..aa455e23a2 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ASRouterTriggerListeners.test.js @@ -0,0 +1,833 @@ +import { ASRouterTriggerListeners } from "modules/ASRouterTriggerListeners.sys.mjs"; +import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterTriggerListeners", () => { + let sandbox; + let globals; + let existingWindow; + let isWindowPrivateStub; + const triggerHandler = () => {}; + const openURLListener = ASRouterTriggerListeners.get("openURL"); + const frequentVisitsListener = ASRouterTriggerListeners.get("frequentVisits"); + const captivePortalLoginListener = + ASRouterTriggerListeners.get("captivePortalLogin"); + const bookmarkedURLListener = + ASRouterTriggerListeners.get("openBookmarkedURL"); + const openArticleURLListener = ASRouterTriggerListeners.get("openArticleURL"); + const nthTabClosedListener = ASRouterTriggerListeners.get("nthTabClosed"); + const idleListener = ASRouterTriggerListeners.get("activityAfterIdle"); + const formAutofillListener = ASRouterTriggerListeners.get("formAutofill"); + const cookieBannerDetectedListener = ASRouterTriggerListeners.get( + "cookieBannerDetected" + ); + const cookieBannerHandledListener = ASRouterTriggerListeners.get( + "cookieBannerHandled" + ); + const hosts = ["www.mozilla.com", "www.mozilla.org"]; + + const regionFake = { + _home: "cn", + _current: "cn", + get home() { + return this._home; + }, + get current() { + return this._current; + }, + }; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + existingWindow = { + gBrowser: { + addTabsProgressListener: sandbox.stub(), + removeTabsProgressListener: sandbox.stub(), + currentURI: { host: "" }, + }, + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }; + sandbox.spy(openURLListener, "init"); + sandbox.spy(openURLListener, "uninit"); + isWindowPrivateStub = sandbox.stub(); + // Assume no window is private so that we execute the action + isWindowPrivateStub.returns(false); + globals.set("PrivateBrowsingUtils", { + isWindowPrivate: isWindowPrivateStub, + }); + const ewUninit = new Map(); + globals.set("EveryWindow", { + registerCallback: (id, init, uninit) => { + init(existingWindow); + ewUninit.set(id, uninit); + }, + unregisterCallback: id => { + ewUninit.get(id)(existingWindow); + }, + }); + globals.set("Region", regionFake); + globals.set("ASRouterPreferences", ASRouterPreferences); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + describe("openBookmarkedURL", () => { + let observerStub; + describe("#init", () => { + beforeEach(() => { + observerStub = sandbox.stub(global.Services.obs, "addObserver"); + sandbox + .stub(global.Services.wm, "getMostRecentBrowserWindow") + .returns({ gBrowser: { selectedBrowser: {} } }); + }); + afterEach(() => { + bookmarkedURLListener.uninit(); + }); + it("should set hosts to the recentBookmarks", async () => { + await bookmarkedURLListener.init(sandbox.stub()); + + assert.calledOnce(observerStub); + assert.calledWithExactly( + observerStub, + bookmarkedURLListener, + "bookmark-icon-updated" + ); + }); + it("should provide id to triggerHandler", async () => { + const newTriggerHandler = sinon.stub(); + const subject = {}; + await bookmarkedURLListener.init(newTriggerHandler); + + bookmarkedURLListener.observe( + subject, + "bookmark-icon-updated", + "starred" + ); + + assert.calledOnce(newTriggerHandler); + assert.calledWithExactly(newTriggerHandler, subject, { + id: bookmarkedURLListener.id, + }); + }); + }); + }); + + describe("captivePortal", () => { + describe("observe", () => { + it("should not call the trigger handler if _shouldShowCaptivePortalVPNPromo returns false", () => { + sandbox + .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo") + .returns(false); + captivePortalLoginListener._triggerHandler = sandbox.spy(); + + captivePortalLoginListener.observe( + null, + "captive-portal-login-success" + ); + + assert.notCalled(captivePortalLoginListener._triggerHandler); + }); + + it("should call the trigger handler if _shouldShowCaptivePortalVPNPromo returns true", () => { + sandbox + .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo") + .returns(true); + sandbox.stub(Services.wm, "getMostRecentBrowserWindow").returns({ + gBrowser: { + selectedBrowser: true, + }, + }); + captivePortalLoginListener._triggerHandler = sandbox.spy(); + + captivePortalLoginListener.observe( + null, + "captive-portal-login-success" + ); + + assert.calledOnce(captivePortalLoginListener._triggerHandler); + }); + }); + }); + + describe("openArticleURL", () => { + describe("#init", () => { + beforeEach(() => { + globals.set( + "MatchPatternSet", + sandbox.stub().callsFake(patterns => ({ + patterns, + matches: url => patterns.has(url), + })) + ); + sandbox.stub(global.AboutReaderParent, "addMessageListener"); + sandbox.stub(global.AboutReaderParent, "removeMessageListener"); + }); + afterEach(() => { + openArticleURLListener.uninit(); + }); + it("setup an event listener on init", () => { + openArticleURLListener.init(sandbox.stub(), hosts, hosts); + + assert.calledOnce(global.AboutReaderParent.addMessageListener); + assert.calledWithExactly( + global.AboutReaderParent.addMessageListener, + openArticleURLListener.readerModeEvent, + sinon.match.object + ); + }); + it("should call triggerHandler correctly for matches [host match]", () => { + const stub = sandbox.stub(); + const target = { currentURI: { host: hosts[0], spec: hosts[1] } }; + openArticleURLListener.init(stub, hosts, hosts); + + const [, { receiveMessage }] = + global.AboutReaderParent.addMessageListener.firstCall.args; + receiveMessage({ data: { isArticle: true }, target }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, target, { + id: openArticleURLListener.id, + param: { host: hosts[0], url: hosts[1] }, + }); + }); + it("should call triggerHandler correctly for matches [pattern match]", () => { + const stub = sandbox.stub(); + const target = { currentURI: { host: null, spec: hosts[1] } }; + openArticleURLListener.init(stub, hosts, hosts); + + const [, { receiveMessage }] = + global.AboutReaderParent.addMessageListener.firstCall.args; + receiveMessage({ data: { isArticle: true }, target }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, target, { + id: openArticleURLListener.id, + param: { host: null, url: hosts[1] }, + }); + }); + it("should remove the message listener", () => { + openArticleURLListener.init(sandbox.stub(), hosts, hosts); + openArticleURLListener.uninit(); + + assert.calledOnce(global.AboutReaderParent.removeMessageListener); + }); + }); + }); + + describe("frequentVisits", () => { + let _triggerHandler; + beforeEach(() => { + _triggerHandler = sandbox.stub(); + sandbox.useFakeTimers(); + frequentVisitsListener.init(_triggerHandler, hosts); + }); + afterEach(() => { + sandbox.clock.restore(); + frequentVisitsListener.uninit(); + }); + it("should be initialized", () => { + assert.isTrue(frequentVisitsListener._initialized); + }); + it("should listen for TabSelect events", () => { + assert.calledOnce(existingWindow.addEventListener); + assert.calledWith( + existingWindow.addEventListener, + "TabSelect", + frequentVisitsListener.onTabSwitch + ); + }); + it("should call _triggerHandler if the visit is valid (is recoreded)", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledOnce(_triggerHandler); + }); + it("should call _triggerHandler only once", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledOnce(_triggerHandler); + }); + it("should call _triggerHandler again after 15 minutes", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + sandbox.clock.tick(15 * 60 * 1000 + 1); + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledTwice(_triggerHandler); + }); + it("should call triggerHandler on valid hosts", () => { + const stub = sandbox.stub(frequentVisitsListener, "triggerHandler"); + existingWindow.gBrowser.currentURI.host = hosts[0]; // eslint-disable-line prefer-destructuring + + frequentVisitsListener.onTabSwitch({ + target: { ownerGlobal: existingWindow }, + }); + + assert.calledOnce(stub); + }); + it("should not call triggerHandler on invalid hosts", () => { + const stub = sandbox.stub(frequentVisitsListener, "triggerHandler"); + existingWindow.gBrowser.currentURI.host = "foo.com"; + + frequentVisitsListener.onTabSwitch({ + target: { ownerGlobal: existingWindow }, + }); + + assert.notCalled(stub); + }); + describe("MatchPattern", () => { + beforeEach(() => { + globals.set( + "MatchPatternSet", + sandbox.stub().callsFake(patterns => ({ patterns: patterns || [] })) + ); + }); + afterEach(() => { + frequentVisitsListener.uninit(); + }); + it("should create a matchPatternSet", () => { + frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]); + + assert.calledOnce(window.MatchPatternSet); + assert.calledWithExactly( + window.MatchPatternSet, + new Set(["pattern"]), + undefined + ); + }); + it("should allow to add multiple patterns and dedupe", () => { + frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]); + frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]); + + assert.calledTwice(window.MatchPatternSet); + assert.calledWithExactly( + window.MatchPatternSet, + new Set(["pattern", "foo"]), + undefined + ); + }); + it("should handle bad arguments to MatchPatternSet", () => { + const badArgs = ["www.example.com"]; + window.MatchPatternSet.withArgs(new Set(badArgs)).throws(); + frequentVisitsListener.init(_triggerHandler, hosts, badArgs); + + // Fails with an empty MatchPatternSet + assert.property(frequentVisitsListener._matchPatternSet, "patterns"); + + // Second try is succesful + frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]); + + assert.property(frequentVisitsListener._matchPatternSet, "patterns"); + assert.isTrue( + frequentVisitsListener._matchPatternSet.patterns.has("foo") + ); + }); + }); + }); + + describe("nthTabClosed", () => { + describe("#init", () => { + beforeEach(() => { + nthTabClosedListener.init(triggerHandler); + }); + afterEach(() => { + nthTabClosedListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + nthTabClosedListener.init(newTriggerHandler); + assert.ok(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, newTriggerHandler); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + assert.calledWith(existingWindow.addEventListener, "TabClose"); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + nthTabClosedListener.init(triggerHandler); + nthTabClosedListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler, closed tabs count", () => { + assert.notOk(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, null); + assert.equal(nthTabClosedListener._closedTabs, 0); + }); + + it("should do nothing if already uninitialised", () => { + nthTabClosedListener.uninit(); + assert.notOk(nthTabClosedListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.calledOnce(existingWindow.removeEventListener); + }); + }); + }); + + describe("activityAfterIdle", () => { + let addObsStub; + let removeObsStub; + describe("#init", () => { + beforeEach(() => { + addObsStub = sandbox.stub(global.Services.obs, "addObserver"); + sandbox + .stub(global.Services.wm, "getEnumerator") + .returns([{ closed: false, document: { hidden: false } }]); + idleListener.init(triggerHandler); + }); + afterEach(() => { + idleListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(idleListener._initialized); + assert.equal(idleListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + idleListener.init(newTriggerHandler); + assert.ok(idleListener._initialized); + assert.equal(idleListener._triggerHandler, newTriggerHandler); + }); + + it("should add observers for idle and activity", () => { + assert.called(addObsStub); + }); + + it("should add event listeners to all existing browser windows", () => { + assert.called(existingWindow.addEventListener); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + removeObsStub = sandbox.stub(global.Services.obs, "removeObserver"); + sandbox.stub(global.Services.wm, "getEnumerator").returns([]); + idleListener.init(triggerHandler); + idleListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(idleListener._initialized); + assert.equal(idleListener._triggerHandler, null); + assert.equal(idleListener._quietSince, null); + }); + + it("should do nothing if already uninitialised", () => { + idleListener.uninit(); + assert.notOk(idleListener._initialized); + }); + + it("should remove observers for idle and activity", () => { + assert.called(removeObsStub); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); + + describe("formAutofill", () => { + let addObsStub; + let removeObsStub; + describe("#init", () => { + beforeEach(() => { + addObsStub = sandbox.stub(global.Services.obs, "addObserver"); + formAutofillListener.init(triggerHandler); + }); + afterEach(() => { + formAutofillListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + formAutofillListener.init(newTriggerHandler); + assert.ok(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, newTriggerHandler); + }); + + it(`should add observer for ${formAutofillListener._topic}`, () => { + assert.called(addObsStub); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + removeObsStub = sandbox.stub(global.Services.obs, "removeObserver"); + formAutofillListener.init(triggerHandler); + formAutofillListener.uninit(); + }); + + it("should set ._initialized to false and clear the triggerHandler", () => { + assert.notOk(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + formAutofillListener.uninit(); + assert.notOk(formAutofillListener._initialized); + }); + + it(`should remove observers for ${formAutofillListener._topic}`, () => { + assert.called(removeObsStub); + }); + }); + }); + + describe("openURL listener", () => { + it("should exist and initially be uninitialised", () => { + assert.ok(openURLListener); + assert.notOk(openURLListener._initialized); + }); + + describe("#init", () => { + beforeEach(() => { + openURLListener.init(triggerHandler, hosts); + }); + afterEach(() => { + openURLListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler and hosts", () => { + assert.ok(openURLListener._initialized); + assert.deepEqual(openURLListener._hosts, new Set(hosts)); + assert.equal(openURLListener._triggerHandler, triggerHandler); + }); + + it("should add tab progress listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.gBrowser.addTabsProgressListener); + assert.calledWithExactly( + existingWindow.gBrowser.addTabsProgressListener, + openURLListener + ); + }); + + it("if already initialised, should only update the trigger handler and add the new hosts", () => { + const newHosts = ["www.example.com"]; + const newTriggerHandler = () => {}; + existingWindow.gBrowser.addTabsProgressListener.reset(); + + openURLListener.init(newTriggerHandler, newHosts); + assert.ok(openURLListener._initialized); + assert.deepEqual( + openURLListener._hosts, + new Set([...hosts, ...newHosts]) + ); + assert.equal(openURLListener._triggerHandler, newTriggerHandler); + assert.notCalled(existingWindow.gBrowser.addTabsProgressListener); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + openURLListener.init(triggerHandler, hosts); + openURLListener.uninit(); + }); + + it("should set ._initialized to false and clear the triggerHandler and hosts", () => { + assert.notOk(openURLListener._initialized); + assert.equal(openURLListener._hosts, null); + assert.equal(openURLListener._triggerHandler, null); + }); + + it("should remove tab progress listeners from all existing browser windows", () => { + assert.calledOnce(existingWindow.gBrowser.removeTabsProgressListener); + assert.calledWithExactly( + existingWindow.gBrowser.removeTabsProgressListener, + openURLListener + ); + }); + + it("should do nothing if already uninitialised", () => { + existingWindow.gBrowser.removeTabsProgressListener.reset(); + + openURLListener.uninit(); + assert.notOk(openURLListener._initialized); + assert.notCalled(existingWindow.gBrowser.removeTabsProgressListener); + }); + }); + + describe("#onLocationChange", () => { + afterEach(() => { + openURLListener.uninit(); + frequentVisitsListener.uninit(); + }); + + it("should call the ._triggerHandler with the right arguments", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const location = "www.mozilla.org"; + openURLListener.onLocationChange(browser, webProgress, undefined, { + host: location, + spec: location, + }); + assert.calledOnce(newTriggerHandler); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => { + for (let trigger of [openURLListener, frequentVisitsListener]) { + const newTriggerHandler = sinon.stub(); + trigger.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + trigger.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.calledOnce(newTriggerHandler); + } + }); + it("should call triggerHandler with the right arguments (redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => { + for (let trigger of [openURLListener, frequentVisitsListener]) { + const newTriggerHandler = sinon.stub(); + trigger.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + trigger.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.calledOnce(newTriggerHandler); + } + }); + it("should call triggerHandler with the right arguments (redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should fail for subdomains (not redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { + spec: "subdomain.mozilla.org", + host: "subdomain.mozilla.org", + }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.notCalled(newTriggerHandler); + }); + }); + }); + + describe("cookieBannerDetected", () => { + describe("#init", () => { + beforeEach(() => { + cookieBannerDetectedListener.init(triggerHandler); + }); + afterEach(() => { + cookieBannerDetectedListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(cookieBannerDetectedListener._initialized); + assert.equal( + cookieBannerDetectedListener._triggerHandler, + triggerHandler + ); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + cookieBannerDetectedListener.init(newTriggerHandler); + assert.ok(cookieBannerDetectedListener._initialized); + assert.equal( + cookieBannerDetectedListener._triggerHandler, + newTriggerHandler + ); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + cookieBannerDetectedListener.init(triggerHandler); + cookieBannerDetectedListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(cookieBannerDetectedListener._initialized); + assert.equal(cookieBannerDetectedListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + cookieBannerDetectedListener.uninit(); + assert.notOk(cookieBannerDetectedListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); + + describe("cookieBannerHandled", () => { + describe("#init", () => { + beforeEach(() => { + cookieBannerHandledListener.init(triggerHandler); + }); + afterEach(() => { + cookieBannerHandledListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(cookieBannerHandledListener._initialized); + assert.equal( + cookieBannerHandledListener._triggerHandler, + triggerHandler + ); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + cookieBannerHandledListener.init(newTriggerHandler); + assert.ok(cookieBannerHandledListener._initialized); + assert.equal( + cookieBannerHandledListener._triggerHandler, + newTriggerHandler + ); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + cookieBannerHandledListener.init(triggerHandler); + cookieBannerHandledListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(cookieBannerHandledListener._initialized); + assert.equal(cookieBannerHandledListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + cookieBannerHandledListener.uninit(); + assert.notOk(cookieBannerHandledListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js new file mode 100644 index 0000000000..fe6959852c --- /dev/null +++ b/browser/components/asrouter/tests/unit/CFRMessageProvider.test.js @@ -0,0 +1,32 @@ +import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; + +const REGULAR_IDS = [ + "FACEBOOK_CONTAINER", + "GOOGLE_TRANSLATE", + "YOUTUBE_ENHANCE", + // These are excluded for now. + // "WIKIPEDIA_CONTEXT_MENU_SEARCH", + // "REDDIT_ENHANCEMENT", +]; + +describe("CFRMessageProvider", () => { + let messages; + beforeEach(async () => { + messages = await CFRMessageProvider.getMessages(); + }); + it("should have a total of 11 messages", () => { + assert.lengthOf(messages, 11); + }); + it("should have one message each for the three regular addons", () => { + for (const id of REGULAR_IDS) { + const cohort3 = messages.find(msg => msg.id === `${id}_3`); + assert.ok(cohort3, `contains three day cohort for ${id}`); + assert.deepEqual( + cohort3.frequency, + { lifetime: 3 }, + "three day cohort has the right frequency cap" + ); + assert.notInclude(cohort3.targeting, `providerCohorts.cfr`); + } + }); +}); diff --git a/browser/components/asrouter/tests/unit/CFRPageActions.test.js b/browser/components/asrouter/tests/unit/CFRPageActions.test.js new file mode 100644 index 0000000000..31970eb43a --- /dev/null +++ b/browser/components/asrouter/tests/unit/CFRPageActions.test.js @@ -0,0 +1,1414 @@ +/* eslint max-nested-callbacks: ["error", 100] */ + +import { CFRPageActions, PageAction } from "modules/CFRPageActions.sys.mjs"; +import { FAKE_RECOMMENDATION } from "./constants"; +import { GlobalOverrider } from "test/unit/utils"; +import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; + +describe("CFRPageActions", () => { + let sandbox; + let clock; + let fakeRecommendation; + let fakeHost; + let fakeBrowser; + let dispatchStub; + let globals; + let containerElem; + let elements; + let announceStub; + let fakeRemoteL10n; + let isElmVisibleStub; + let getWidgetStub; + + const elementIDs = [ + "urlbar", + "urlbar-input", + "contextual-feature-recommendation", + "cfr-button", + "cfr-label", + "contextual-feature-recommendation-notification", + "cfr-notification-header-label", + "cfr-notification-header-link", + "cfr-notification-header-image", + "cfr-notification-author", + "cfr-notification-footer", + "cfr-notification-footer-text", + "cfr-notification-footer-filled-stars", + "cfr-notification-footer-empty-stars", + "cfr-notification-footer-users", + "cfr-notification-footer-spacer", + "cfr-notification-footer-learn-more-link", + ]; + const elementClassNames = ["popup-notification-body-container"]; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sandbox.useFakeTimers(); + isElmVisibleStub = sandbox.stub().returns(true); + getWidgetStub = sandbox.stub(); + + announceStub = sandbox.stub(); + const A11yUtils = { announce: announceStub }; + fakeRecommendation = { ...FAKE_RECOMMENDATION }; + fakeHost = "mozilla.org"; + fakeBrowser = { + documentURI: { + scheme: "https", + host: fakeHost, + }, + ownerGlobal: window, + }; + dispatchStub = sandbox.stub(); + + fakeRemoteL10n = { + l10n: {}, + reloadL10n: sandbox.stub(), + createElement: sandbox.stub().returns(document.createElement("div")), + }; + + const gURLBar = document.createElement("div"); + gURLBar.textbox = document.createElement("div"); + + globals = new GlobalOverrider(); + globals.set({ + RemoteL10n: fakeRemoteL10n, + promiseDocumentFlushed: sandbox + .stub() + .callsFake(fn => Promise.resolve(fn())), + PopupNotifications: { + show: sandbox.stub(), + remove: sandbox.stub(), + }, + PrivateBrowsingUtils: { isWindowPrivate: sandbox.stub().returns(false) }, + gBrowser: { selectedBrowser: fakeBrowser }, + A11yUtils, + gURLBar, + isElementVisible: isElmVisibleStub, + CustomizableUI: { getWidget: getWidgetStub }, + }); + document.createXULElement = document.createElement; + + elements = {}; + const [body] = document.getElementsByTagName("body"); + containerElem = document.createElement("div"); + body.appendChild(containerElem); + for (const id of elementIDs) { + const elem = document.createElement("div"); + elem.setAttribute("id", id); + containerElem.appendChild(elem); + elements[id] = elem; + } + for (const className of elementClassNames) { + const elem = document.createElement("div"); + elem.setAttribute("class", className); + containerElem.appendChild(elem); + elements[className] = elem; + } + }); + + afterEach(() => { + CFRPageActions.clearRecommendations(); + containerElem.remove(); + sandbox.restore(); + globals.restore(); + }); + + describe("PageAction", () => { + let pageAction; + + beforeEach(() => { + pageAction = new PageAction(window, dispatchStub); + }); + + describe("#addImpression", () => { + it("should call _sendTelemetry with the impression payload", () => { + const recommendation = { + id: "foo", + content: { bucket_id: "bar" }, + }; + sandbox.spy(pageAction, "_sendTelemetry"); + + pageAction.addImpression(recommendation); + + assert.calledWith(pageAction._sendTelemetry, { + message_id: "foo", + bucket_id: "bar", + event: "IMPRESSION", + }); + }); + }); + + describe("#showAddressBarNotifier", () => { + it("should un-hideAddressBarNotifier the element and set the right label value", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.isFalse(pageAction.container.hidden); + assert.equal( + pageAction.label.value, + fakeRecommendation.content.notification_text + ); + }); + it("should wait for the document layout to flush", async () => { + sandbox.spy(pageAction.label, "getClientRects"); + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.calledOnce(global.promiseDocumentFlushed); + assert.callOrder( + global.promiseDocumentFlushed, + pageAction.label.getClientRects + ); + }); + it("should set the CSS variable --cfr-label-width correctly", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation); + const expectedWidth = pageAction.label.getClientRects()[0].width; + assert.equal( + pageAction.urlbarinput.style.getPropertyValue("--cfr-label-width"), + `${expectedWidth}px` + ); + }); + it("should cause an expansion, and dispatch an impression if `expand` is true", async () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + sandbox.spy(pageAction, "_expand"); + sandbox.spy(pageAction, "_dispatchImpression"); + + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.notCalled(pageAction._dispatchImpression); + clock.tick(1001); + assert.notEqual( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledOnce(pageAction._clearScheduledStateChanges); + clock.tick(1001); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + assert.calledOnce(pageAction._dispatchImpression); + assert.calledWith(pageAction._dispatchImpression, fakeRecommendation); + }); + it("should send telemetry if `expand` is true and the id and bucket_id are provided", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "IMPRESSION", + }, + }); + }); + }); + + describe("#hideAddressBarNotifier", () => { + it("should hideAddressBarNotifier the container, cancel any state changes, and remove the state attribute", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction.hideAddressBarNotifier(); + assert.isTrue(pageAction.container.hidden); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.isNull( + pageAction.urlbar.getAttribute("cfr-recommendation-state") + ); + }); + it("should remove the `currentNotification`", () => { + const notification = {}; + pageAction.currentNotification = notification; + pageAction.hideAddressBarNotifier(); + assert.calledWith(global.PopupNotifications.remove, notification); + }); + }); + + describe("#_expand", () => { + beforeEach(() => { + pageAction._clearScheduledStateChanges(); + pageAction.urlbar.removeAttribute("cfr-recommendation-state"); + }); + it("without a delay, should clear other state changes and set the state to 'expanded'", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction._expand(); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + }); + it("with a delay, should set the expanded state after the correct amount of time", () => { + const delay = 1234; + pageAction._expand(delay); + // We expect that an expansion has been scheduled + assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1); + clock.tick(delay + 1); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + }); + }); + + describe("#_collapse", () => { + beforeEach(() => { + pageAction._clearScheduledStateChanges(); + pageAction.urlbar.removeAttribute("cfr-recommendation-state"); + }); + it("without a delay, should clear other state changes and set the state to collapsed only if it's already expanded", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction._collapse(); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.isNull( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state") + ); + pageAction.urlbarinput.setAttribute( + "cfr-recommendation-state", + "expanded" + ); + pageAction._collapse(); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + }); + it("with a delay, should set the collapsed state after the correct amount of time", () => { + const delay = 1234; + pageAction._collapse(delay); + clock.tick(delay + 1); + // The state was _not_ "expanded" and so should not have been set to "collapsed" + assert.isNull( + pageAction.urlbar.getAttribute("cfr-recommendation-state") + ); + + pageAction._expand(); + pageAction._collapse(delay); + // We expect that a collapse has been scheduled + assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1); + clock.tick(delay + 1); + // This time it was "expanded" so should now (after the delay) be "collapsed" + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + }); + }); + + describe("#_clearScheduledStateChanges", () => { + it("should call .clearTimeout on all stored timeoutIDs", () => { + pageAction.stateTransitionTimeoutIDs = [42, 73, 1997]; + sandbox.spy(pageAction.window, "clearTimeout"); + pageAction._clearScheduledStateChanges(); + assert.calledThrice(pageAction.window.clearTimeout); + assert.calledWith(pageAction.window.clearTimeout, 42); + assert.calledWith(pageAction.window.clearTimeout, 73); + assert.calledWith(pageAction.window.clearTimeout, 1997); + }); + }); + + describe("#_popupStateChange", () => { + it("should collapse the notification and send dismiss telemetry on 'dismissed'", () => { + pageAction._expand(); + + sandbox.spy(pageAction, "_sendTelemetry"); + + pageAction._popupStateChange("dismissed"); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + + assert.equal( + pageAction._sendTelemetry.lastCall.args[0].event, + "DISMISS" + ); + }); + it("should remove the notification on 'removed'", () => { + pageAction._expand(); + const fakeNotification = {}; + + pageAction.currentNotification = fakeNotification; + pageAction._popupStateChange("removed"); + assert.calledOnce(global.PopupNotifications.remove); + assert.calledWith(global.PopupNotifications.remove, fakeNotification); + }); + it("should do nothing for other states", () => { + pageAction._popupStateChange("opened"); + assert.notCalled(global.PopupNotifications.remove); + }); + }); + + describe("#dispatchUserAction", () => { + it("should call ._dispatchCFRAction with the right action", () => { + const fakeAction = {}; + pageAction.dispatchUserAction(fakeAction); + assert.calledOnce(dispatchStub); + assert.calledWith( + dispatchStub, + { type: "USER_ACTION", data: fakeAction }, + fakeBrowser + ); + }); + }); + + describe("#_dispatchImpression", () => { + it("should call ._dispatchCFRAction with the right action", () => { + pageAction._dispatchImpression("fake impression"); + assert.calledWith(dispatchStub, { + type: "IMPRESSION", + data: "fake impression", + }); + }); + }); + + describe("#_sendTelemetry", () => { + it("should call ._dispatchCFRAction with the right action", () => { + const fakePing = { message_id: 42 }; + pageAction._sendTelemetry(fakePing); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: 42, + }, + }); + }); + }); + + describe("#_blockMessage", () => { + it("should call ._dispatchCFRAction with the right action", () => { + pageAction._blockMessage("fake id"); + assert.calledOnce(dispatchStub); + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: "fake id" }, + }); + }); + }); + + describe("#getStrings", () => { + let formatMessagesStub; + const localeStrings = [ + { + value: "ä½ å¥½ä¸–ç•Œ", + attributes: [ + { name: "first_attr", value: 42 }, + { name: "second_attr", value: "some string" }, + { name: "third_attr", value: [1, 2, 3] }, + ], + }, + ]; + + beforeEach(() => { + formatMessagesStub = sandbox + .stub() + .withArgs({ id: "hello_world" }) + .resolves(localeStrings); + global.RemoteL10n.l10n.formatMessages = formatMessagesStub; + }); + + it("should return the argument if a string_id is not defined", async () => { + assert.deepEqual(await pageAction.getStrings({}), {}); + assert.equal(await pageAction.getStrings("some string"), "some string"); + }); + it("should get the right locale string", async () => { + assert.equal( + await pageAction.getStrings({ string_id: "hello_world" }), + localeStrings[0].value + ); + }); + it("should return the right sub-attribute if specified", async () => { + assert.equal( + await pageAction.getStrings( + { string_id: "hello_world" }, + "second_attr" + ), + "some string" + ); + }); + it("should attach attributes to string overrides", async () => { + const fromJson = { value: "Add Now", attributes: { accesskey: "A" } }; + + const result = await pageAction.getStrings(fromJson); + + assert.equal(result, fromJson.value); + assert.propertyVal(result.attributes, "accesskey", "A"); + }); + it("should return subAttributes when doing string overrides", async () => { + const fromJson = { value: "Add Now", attributes: { accesskey: "A" } }; + + const result = await pageAction.getStrings(fromJson, "accesskey"); + + assert.equal(result, "A"); + }); + it("should resolve ftl strings and attach subAttributes", async () => { + const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" }; + formatMessagesStub.resolves([ + { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] }, + ]); + + const result = await pageAction.getStrings(fromFtl); + + assert.equal(result, "Add Now"); + assert.propertyVal(result.attributes, "accesskey", "A"); + }); + it("should return subAttributes from ftl ids", async () => { + const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" }; + formatMessagesStub.resolves([ + { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] }, + ]); + + const result = await pageAction.getStrings(fromFtl, "accesskey"); + + assert.equal(result, "A"); + }); + it("should report an error when no attributes are present but subAttribute is requested", async () => { + const fromJson = { value: "Foo" }; + const stub = sandbox.stub(global.console, "error"); + + await pageAction.getStrings(fromJson, "accesskey"); + + assert.calledOnce(stub); + stub.restore(); + }); + }); + + describe("#_cfrUrlbarButtonClick", () => { + let translateElementsStub; + let setAttributesStub; + let getStringsStub; + beforeEach(async () => { + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + getStringsStub = sandbox.stub(pageAction, "getStrings").resolves(""); + getStringsStub + .callsFake(async a => a) // eslint-disable-line max-nested-callbacks + .withArgs({ string_id: "primary_button_id" }) + .resolves({ value: "Primary Button", attributes: { accesskey: "p" } }) + .withArgs({ string_id: "secondary_button_id" }) + .resolves({ + value: "Secondary Button", + attributes: { accesskey: "s" }, + }) + .withArgs({ string_id: "secondary_button_id_2" }) + .resolves({ + value: "Secondary Button 2", + attributes: { accesskey: "a" }, + }) + .withArgs({ string_id: "secondary_button_id_3" }) + .resolves({ + value: "Secondary Button 3", + attributes: { accesskey: "g" }, + }) + .withArgs( + sinon.match({ + string_id: "cfr-doorhanger-extension-learn-more-link", + }) + ) + .resolves("Learn more") + .withArgs( + sinon.match({ string_id: "cfr-doorhanger-extension-total-users" }) + ) + .callsFake(async ({ args }) => `${args.total} users`); // eslint-disable-line max-nested-callbacks + + translateElementsStub = sandbox.stub().resolves(); + setAttributesStub = sandbox.stub(); + global.RemoteL10n.l10n.setAttributes = setAttributesStub; + global.RemoteL10n.l10n.translateElements = translateElementsStub; + }); + + it("should call `.hideAddressBarNotifier` and do nothing if there is no recommendation for the selected browser", async () => { + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.delete(fakeBrowser); + await pageAction._cfrUrlbarButtonClick({}); + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.notCalled(global.PopupNotifications.show); + }); + it("should cancel any planned state changes", async () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + assert.notCalled(pageAction._clearScheduledStateChanges); + await pageAction._cfrUrlbarButtonClick({}); + assert.calledOnce(pageAction._clearScheduledStateChanges); + }); + it("should set the right text values", async () => { + await pageAction._cfrUrlbarButtonClick({}); + const headerLabel = elements["cfr-notification-header-label"]; + const headerLink = elements["cfr-notification-header-link"]; + const headerImage = elements["cfr-notification-header-image"]; + const footerLink = elements["cfr-notification-footer-learn-more-link"]; + assert.equal( + headerLabel.value, + fakeRecommendation.content.heading_text + ); + assert.isTrue( + headerLink + .getAttribute("href") + .endsWith(fakeRecommendation.content.info_icon.sumo_path) + ); + assert.equal( + headerImage.getAttribute("tooltiptext"), + fakeRecommendation.content.info_icon.label + ); + const htmlFooterEl = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => + args && args.content === fakeRecommendation.content.text + ); + assert.ok(htmlFooterEl); + assert.equal(footerLink.value, "Learn more"); + assert.equal( + footerLink.getAttribute("href"), + fakeRecommendation.content.addon.amo_url + ); + }); + it("should add the rating correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + const footerFilledStars = + elements["cfr-notification-footer-filled-stars"]; + const footerEmptyStars = + elements["cfr-notification-footer-empty-stars"]; + // .toFixed to sort out some floating precision errors + assert.equal( + footerFilledStars.style.width, + `${(4.2 * 16).toFixed(1)}px` + ); + assert.equal( + footerEmptyStars.style.width, + `${(0.8 * 16).toFixed(1)}px` + ); + }); + it("should add the number of users correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + const footerUsers = elements["cfr-notification-footer-users"]; + assert.isNull(footerUsers.getAttribute("hidden")); + assert.equal( + footerUsers.getAttribute("value"), + `${fakeRecommendation.content.addon.users}` + ); + }); + it("should send the right telemetry", async () => { + await pageAction._cfrUrlbarButtonClick(); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "CLICK_DOORHANGER", + }, + }); + }); + it("should set the main action correctly", async () => { + sinon + .stub(CFRPageActions, "_fetchLatestAddonVersion") + .resolves("latest-addon.xpi"); + await pageAction._cfrUrlbarButtonClick(); + const mainAction = global.PopupNotifications.show.firstCall.args[4]; // eslint-disable-line prefer-destructuring + assert.deepEqual(mainAction.label, { + value: "Primary Button", + attributes: { accesskey: "p" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + await mainAction.callback(); + assert.calledOnce(pageAction.hideAddressBarNotifier); + // Should block the message + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: fakeRecommendation.id }, + }); + // Should trigger the action + assert.calledWith( + dispatchStub, + { + type: "USER_ACTION", + data: { id: "primary_action", data: { url: "latest-addon.xpi" } }, + }, + fakeBrowser + ); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "INSTALL", + }, + }); + // Should remove the recommendation + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should set the secondary action correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const [secondaryAction] = + global.PopupNotifications.show.firstCall.args[5]; + + assert.deepEqual(secondaryAction.label, { + value: "Secondary Button", + attributes: { accesskey: "s" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + secondaryAction.callback(); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "DISMISS", + }, + }); + // Don't remove the recommendation on `DISMISS` action + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + assert.notCalled(pageAction.hideAddressBarNotifier); + }); + it("should send right telemetry for BLOCK secondary action", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const blockAction = global.PopupNotifications.show.firstCall.args[5][1]; + + assert.deepEqual(blockAction.label, { + value: "Secondary Button 2", + attributes: { accesskey: "a" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + sandbox.spy(pageAction, "_blockMessage"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + blockAction.callback(); + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.calledOnce(pageAction._blockMessage); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "BLOCK", + }, + }); + // Should remove the recommendation + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should send right telemetry for MANAGE secondary action", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const manageAction = + global.PopupNotifications.show.firstCall.args[5][2]; + + assert.deepEqual(manageAction.label, { + value: "Secondary Button 3", + attributes: { accesskey: "g" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + manageAction.callback(); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "MANAGE", + }, + }); + // Don't remove the recommendation on `MANAGE` action + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + assert.notCalled(pageAction.hideAddressBarNotifier); + }); + it("should call PopupNotifications.show with the right arguments", async () => { + await pageAction._cfrUrlbarButtonClick(); + assert.calledWith( + global.PopupNotifications.show, + fakeBrowser, + "contextual-feature-recommendation", + fakeRecommendation.content.addon.title, + "cfr", + sinon.match.any, // Corresponds to the main action, tested above + sinon.match.any, // Corresponds to the secondary action, tested above + { + popupIconURL: fakeRecommendation.content.addon.icon, + hideClose: true, + eventCallback: pageAction._popupStateChange, + persistent: false, + persistWhileVisible: false, + popupIconClass: fakeRecommendation.content.icon_class, + recordTelemetryInPrivateBrowsing: + fakeRecommendation.content.show_in_private_browsing, + name: { + string_id: "cfr-doorhanger-extension-author", + args: { name: fakeRecommendation.content.addon.author }, + }, + } + ); + }); + }); + describe("#_cfrUrlbarButtonClick/cfr_urlbar_chiclet", () => { + let heartbeatRecommendation; + beforeEach(async () => { + heartbeatRecommendation = (await CFRMessageProvider.getMessages()).find( + m => m.template === "cfr_urlbar_chiclet" + ); + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + heartbeatRecommendation, + dispatchStub + ); + }); + it("should dispatch a click event", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: heartbeatRecommendation.id, + bucket_id: heartbeatRecommendation.content.bucket_id, + event: "CLICK_DOORHANGER", + }, + }); + }); + it("should dispatch a USER_ACTION for chiclet_open_url layout", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "USER_ACTION", + data: { + data: { + args: heartbeatRecommendation.content.action.url, + where: heartbeatRecommendation.content.action.where, + }, + type: "OPEN_URL", + }, + }); + }); + it("should block the message after the click", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: heartbeatRecommendation.id }, + }); + }); + it("should remove the button and browser entry", async () => { + sandbox.spy(pageAction, "hideAddressBarNotifier"); + + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + }); + + describe("#showMilestonePopup", () => { + let milestoneRecommendation; + let fakeTrackingDBService; + beforeEach(async () => { + fakeTrackingDBService = { + sumAllEvents: sandbox.stub(), + }; + globals.set({ TrackingDBService: fakeTrackingDBService }); + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + sandbox + .stub(pageAction, "getStrings") + .callsFake(async a => a) // eslint-disable-line max-nested-callbacks + .resolves({ value: "element", attributes: { accesskey: "e" } }); + + milestoneRecommendation = (await CFRMessageProvider.getMessages()).find( + m => m.template === "milestone_message" + ); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("Set current date in header when earliest date undefined", async () => { + fakeTrackingDBService.getEarliestRecordedDate = sandbox.stub(); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + milestoneRecommendation, + dispatchStub + ); + const [, , headerElementArgs] = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => args && args.content && args.attributes + ); + assert.equal( + headerElementArgs.content.string_id, + milestoneRecommendation.content.heading_text.string_id + ); + assert.equal(headerElementArgs.attributes.date, new Date().getTime()); + assert.calledOnce(global.PopupNotifications.show); + }); + + it("Set date in header to earliest date timestamp by default", async () => { + let earliestDateTimeStamp = 1705601996435; + fakeTrackingDBService.getEarliestRecordedDate = sandbox + .stub() + .returns(earliestDateTimeStamp); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + milestoneRecommendation, + dispatchStub + ); + const [, , headerElementArgs] = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => args && args.content && args.attributes + ); + assert.equal( + headerElementArgs.content.string_id, + milestoneRecommendation.content.heading_text.string_id + ); + assert.equal(headerElementArgs.attributes.date, earliestDateTimeStamp); + assert.calledOnce(global.PopupNotifications.show); + }); + }); + }); + + describe("CFRPageActions", () => { + beforeEach(() => { + // Spy on the prototype methods to inspect calls for any PageAction instance + sandbox.spy(PageAction.prototype, "showAddressBarNotifier"); + sandbox.spy(PageAction.prototype, "hideAddressBarNotifier"); + }); + + describe("updatePageActions", () => { + let savedRec; + + beforeEach(() => { + const win = fakeBrowser.ownerGlobal; + CFRPageActions.PageActionMap.set( + win, + new PageAction(win, dispatchStub) + ); + const { id, content } = fakeRecommendation; + savedRec = { + id, + host: fakeHost, + content, + }; + CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec); + }); + + it("should do nothing if a pageAction doesn't exist for the window", () => { + const win = fakeBrowser.ownerGlobal; + CFRPageActions.PageActionMap.delete(win); + CFRPageActions.updatePageActions(fakeBrowser); + assert.notCalled(PageAction.prototype.showAddressBarNotifier); + assert.notCalled(PageAction.prototype.hideAddressBarNotifier); + }); + it("should do nothing if the browser is not the `selectedBrowser`", () => { + const someOtherFakeBrowser = {}; + CFRPageActions.updatePageActions(someOtherFakeBrowser); + assert.notCalled(PageAction.prototype.showAddressBarNotifier); + assert.notCalled(PageAction.prototype.hideAddressBarNotifier); + }); + it("should hideAddressBarNotifier the pageAction if a recommendation doesn't exist for the given browser", () => { + CFRPageActions.RecommendationMap.delete(fakeBrowser); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + }); + it("should show the pageAction if a recommendation exists and the host matches", () => { + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + assert.calledWith( + PageAction.prototype.showAddressBarNotifier, + savedRec + ); + }); + it("should show the pageAction if a recommendation exists and it doesn't have a host defined", () => { + const recNoHost = { ...savedRec, host: undefined }; + CFRPageActions.RecommendationMap.set(fakeBrowser, recNoHost); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + assert.calledWith( + PageAction.prototype.showAddressBarNotifier, + recNoHost + ); + }); + it("should hideAddressBarNotifier the pageAction and delete the recommendation if the recommendation exists but the host doesn't match", () => { + const someOtherFakeHost = "subdomain.mozilla.com"; + fakeBrowser.documentURI.host = someOtherFakeHost; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should not call `delete` if retain is true", () => { + savedRec.retain = true; + fakeBrowser.documentURI.host = "subdomain.mozilla.com"; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + CFRPageActions.updatePageActions(fakeBrowser); + assert.propertyVal(savedRec, "retain", false); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should call `delete` if retain is false", () => { + savedRec.retain = false; + fakeBrowser.documentURI.host = "subdomain.mozilla.com"; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + CFRPageActions.updatePageActions(fakeBrowser); + assert.propertyVal(savedRec, "retain", false); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + }); + + describe("forceRecommendation", () => { + it("should succeed and add an element to the RecommendationMap", async () => { + assert.isTrue( + await CFRPageActions.forceRecommendation( + fakeBrowser, + fakeRecommendation, + dispatchStub + ) + ); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + content: fakeRecommendation.content, + }); + }); + it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => { + const win = fakeBrowser.ownerGlobal; + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + await CFRPageActions.forceRecommendation( + fakeBrowser, + fakeRecommendation, + dispatchStub + ); + const pageAction = CFRPageActions.PageActionMap.get(win); + assert.equal(win, pageAction.window); + assert.equal(dispatchStub, pageAction._dispatchCFRAction); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + }); + }); + + describe("showPopup", () => { + let savedRec; + let pageAction; + let fakeAnchorId = "fake_anchor_id"; + let fakeAltAnchorId = "fake_alt_anchor_id"; + let TEST_MESSAGE; + let getElmStub; + let getStyleStub; + let isCustomizingStub; + beforeEach(() => { + TEST_MESSAGE = { + id: "fake_id", + template: "cfr_doorhanger", + content: { + skip_address_bar_notifier: true, + heading_text: "Fake Heading Text", + anchor_id: fakeAnchorId, + }, + }; + getElmStub = sandbox + .stub(window.document, "getElementById") + .callsFake(id => ({ id })); + getStyleStub = sandbox + .stub(window, "getComputedStyle") + .returns({ display: "block", visibility: "visible" }); + + isCustomizingStub = sandbox.stub().returns(false); + globals.set({ + CustomizationHandler: { isCustomizing: isCustomizingStub }, + }); + + savedRec = { + id: TEST_MESSAGE.id, + host: fakeHost, + content: TEST_MESSAGE.content, + }; + CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec); + pageAction = new PageAction(window, dispatchStub); + sandbox.stub(pageAction, "_renderPopup"); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should use anchor_id if element exists and is not a customizable widget", async () => { + await pageAction.showPopup(); + assert.equal(fakeBrowser.cfrpopupnotificationanchor.id, fakeAnchorId); + }); + + it("should use anchor_id if element exists and is in the toolbar", async () => { + getWidgetStub.withArgs(fakeAnchorId).returns({ areaType: "toolbar" }); + await pageAction.showPopup(); + assert.equal(fakeBrowser.cfrpopupnotificationanchor.id, fakeAnchorId); + }); + + it("should use the cfr button if element exists but is in the widget overflow panel", async () => { + getWidgetStub + .withArgs(fakeAnchorId) + .returns({ areaType: "menu-panel" }); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.button.id + ); + }); + + it("should use the cfr button if element exists but is in the customization palette", async () => { + getWidgetStub.withArgs(fakeAnchorId).returns({ areaType: null }); + isCustomizingStub.returns(true); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.button.id + ); + }); + + it("should use alt_anchor_id if one has been provided and the anchor_id element cannot be found", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getElmStub.withArgs(fakeAnchorId).returns(null); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + fakeAltAnchorId + ); + }); + + it("should use alt_anchor_id if one has been provided and the anchor_id element is hidden by CSS", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getStyleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns({ display: "none", visibility: "visible" }); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + fakeAltAnchorId + ); + }); + + it("should use alt_anchor_id if one has been provided and the anchor_id element has no height/width", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + isElmVisibleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns(false); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + fakeAltAnchorId + ); + }); + + it("should use the button if the anchor_id and alt_anchor_id are both not visible", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getStyleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns({ display: "none", visibility: "visible" }); + getWidgetStub.withArgs(fakeAltAnchorId).returns({ areaType: null }); + isCustomizingStub.returns(true); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.button.id + ); + }); + + it("should use the default container if the anchor_id, alt_anchor_id, and cfr button are not visible", async () => { + TEST_MESSAGE.content.alt_anchor_id = fakeAltAnchorId; + getStyleStub + .withArgs(sandbox.match({ id: fakeAnchorId })) + .returns({ display: "none", visibility: "visible" }); + getStyleStub + .withArgs(sandbox.match({ id: "cfr-button" })) + .returns({ display: "none", visibility: "visible" }); + getWidgetStub.withArgs(fakeAltAnchorId).returns({ areaType: null }); + isCustomizingStub.returns(true); + await pageAction.showPopup(); + assert.equal( + fakeBrowser.cfrpopupnotificationanchor.id, + pageAction.container.id + ); + }); + }); + + describe("addRecommendation", () => { + it("should fail and not add a recommendation if the browser is part of a private window", async () => { + global.PrivateBrowsingUtils.isWindowPrivate.returns(true); + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should successfully add a private browsing recommendation and send correct telemetry", async () => { + global.PrivateBrowsingUtils.isWindowPrivate.returns(true); + fakeRecommendation.content.show_in_private_browsing = true; + assert.isTrue( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + const pageAction = CFRPageActions.PageActionMap.get( + fakeBrowser.ownerGlobal + ); + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + is_private: true, + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "IMPRESSION", + }, + }); + }); + it("should fail and not add a recommendation if the browser is not the selected browser", async () => { + global.gBrowser.selectedBrowser = {}; // Some other browser + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + }); + it("should fail and not add a recommendation if the browser does not exist", async () => { + assert.isFalse( + await CFRPageActions.addRecommendation( + undefined, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should fail and not add a recommendation if the host doesn't match", async () => { + const someOtherFakeHost = "subdomain.mozilla.com"; + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + someOtherFakeHost, + fakeRecommendation, + dispatchStub + ) + ); + }); + it("should otherwise succeed and add an element to the RecommendationMap", async () => { + assert.isTrue( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + }); + it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => { + const win = fakeBrowser.ownerGlobal; + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + const pageAction = CFRPageActions.PageActionMap.get(win); + assert.equal(win, pageAction.window); + assert.equal(dispatchStub, pageAction._dispatchCFRAction); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + }); + it("should add the right url if we fetched and addon install URL", async () => { + fakeRecommendation.template = "cfr_doorhanger"; + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + const recommendation = + CFRPageActions.RecommendationMap.get(fakeBrowser); + + // sanity check - just go through some of the rest of the attributes to make sure they were untouched + assert.equal(recommendation.id, fakeRecommendation.id); + assert.equal( + recommendation.content.heading_text, + fakeRecommendation.content.heading_text + ); + assert.equal( + recommendation.content.addon, + fakeRecommendation.content.addon + ); + assert.equal( + recommendation.content.text, + fakeRecommendation.content.text + ); + assert.equal( + recommendation.content.buttons.secondary, + fakeRecommendation.content.buttons.secondary + ); + assert.equal( + recommendation.content.buttons.primary.action.id, + fakeRecommendation.content.buttons.primary.action.id + ); + + delete fakeRecommendation.template; + }); + it("should prevent a second message if one is currently displayed", async () => { + const secondMessage = { ...fakeRecommendation, id: "second_message" }; + let messageAdded = await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + + assert.isTrue(messageAdded); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + + messageAdded = await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + secondMessage, + dispatchStub + ); + // Adding failed + assert.isFalse(messageAdded); + // First message is still there + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + }); + it("should send impressions just for the first message", async () => { + const secondMessage = { ...fakeRecommendation, id: "second_message" }; + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + secondMessage, + dispatchStub + ); + + // Doorhanger telemetry + Impression for just 1 message + assert.calledTwice(dispatchStub); + const [firstArgs] = dispatchStub.firstCall.args; + const [secondArgs] = dispatchStub.secondCall.args; + assert.equal(firstArgs.data.id, secondArgs.data.message_id); + }); + }); + + describe("clearRecommendations", () => { + const createFakePageAction = () => ({ + hideAddressBarNotifier: sandbox.stub(), + }); + const windows = [{}, {}, { closed: true }]; + const browsers = [{}, {}, {}, {}]; + + beforeEach(() => { + CFRPageActions.PageActionMap.set(windows[0], createFakePageAction()); + CFRPageActions.PageActionMap.set(windows[2], createFakePageAction()); + for (const browser of browsers) { + CFRPageActions.RecommendationMap.set(browser, {}); + } + globals.set({ Services: { wm: { getEnumerator: () => windows } } }); + }); + + it("should hideAddressBarNotifier the PageActions of any existing, non-closed windows", () => { + const pageActions = windows.map(win => + CFRPageActions.PageActionMap.get(win) + ); + CFRPageActions.clearRecommendations(); + + // Only the first window had a PageAction and wasn't closed + assert.calledOnce(pageActions[0].hideAddressBarNotifier); + assert.isUndefined(pageActions[1]); + assert.notCalled(pageActions[2].hideAddressBarNotifier); + }); + it("should clear the PageActionMap and the RecommendationMap", () => { + CFRPageActions.clearRecommendations(); + + // Both are WeakMaps and so are not iterable, cannot be cleared, and + // cannot have their length queried directly, so we have to check + // whether previous elements still exist + assert.lengthOf(windows, 3); + for (const win of windows) { + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + } + assert.lengthOf(browsers, 4); + for (const browser of browsers) { + assert.isFalse(CFRPageActions.RecommendationMap.has(browser)); + } + }); + }); + + describe("reloadL10n", () => { + const createFakePageAction = () => ({ + hideAddressBarNotifier() {}, + reloadL10n: sandbox.stub(), + }); + const windows = [{}, {}, { closed: true }]; + + beforeEach(() => { + CFRPageActions.PageActionMap.set(windows[0], createFakePageAction()); + CFRPageActions.PageActionMap.set(windows[2], createFakePageAction()); + globals.set({ Services: { wm: { getEnumerator: () => windows } } }); + }); + + it("should call reloadL10n for all the PageActions of any existing, non-closed windows", () => { + const pageActions = windows.map(win => + CFRPageActions.PageActionMap.get(win) + ); + CFRPageActions.reloadL10n(); + + // Only the first window had a PageAction and wasn't closed + assert.calledOnce(pageActions[0].reloadL10n); + assert.isUndefined(pageActions[1]); + assert.notCalled(pageActions[2].reloadL10n); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js b/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js new file mode 100644 index 0000000000..463e388651 --- /dev/null +++ b/browser/components/asrouter/tests/unit/MessageLoaderUtils.test.js @@ -0,0 +1,459 @@ +import { MessageLoaderUtils } from "modules/ASRouter.sys.mjs"; +const { STARTPAGE_VERSION } = MessageLoaderUtils; + +const FAKE_OPTIONS = { + storage: { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve(); + }, + }, + dispatchToAS: () => {}, +}; +const FAKE_RESPONSE_HEADERS = { get() {} }; + +describe("MessageLoaderUtils", () => { + let fetchStub; + let clock; + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + fetchStub = sinon.stub(global, "fetch"); + }); + afterEach(() => { + sandbox.restore(); + clock.restore(); + fetchStub.restore(); + }); + + describe("#loadMessagesForProvider", () => { + it("should return messages for a local provider with hardcoded messages", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "local", + messages: [sourceMessage], + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.isArray(result.messages); + // Does the message have the right properties? + const [message] = result.messages; + assert.propertyVal(message, "id", "foo"); + assert.propertyVal(message, "provider", "provider123"); + }); + it("should filter out local messages listed in the `exclude` field", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "local", + messages: [sourceMessage], + exclude: ["foo"], + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.lengthOf(result.messages, 0); + }); + it("should return messages for remote provider", async () => { + const sourceMessage = { id: "foo" }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: [sourceMessage] }), + headers: FAKE_RESPONSE_HEADERS, + }); + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.isArray(result.messages); + // Does the message have the right properties? + const [message] = result.messages; + assert.propertyVal(message, "id", "foo"); + assert.propertyVal(message, "provider", "provider123"); + assert.propertyVal(message, "provider_url", "https://foo.com"); + }); + describe("remote provider HTTP codes", () => { + const testMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + updateCycleInMs: 300, + }; + const respJson = { messages: [testMessage] }; + + function assertReturnsCorrectMessages(actual) { + assert.isArray(actual.messages); + // Does the message have the right properties? + const [message] = actual.messages; + assert.propertyVal(message, "id", testMessage.id); + assert.propertyVal(message, "provider", provider.id); + assert.propertyVal(message, "provider_url", provider.url); + } + + it("should return messages for 200 response", async () => { + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + assertReturnsCorrectMessages( + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ) + ); + }); + + it("should return messages for a 302 response with json", async () => { + fetchStub.resolves({ + ok: true, + status: 302, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + assertReturnsCorrectMessages( + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ) + ); + }); + + it("should return an empty array for a 204 response", async () => { + fetchStub.resolves({ + ok: true, + status: 204, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should return an empty array for a 500 response", async () => { + fetchStub.resolves({ + ok: false, + status: 500, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should return cached messages for a 304 response", async () => { + clock.tick(302); + const messages = [{ id: "message-1" }, { id: "message-2" }]; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages, + etag: "etag0987654321", + lastFetched: 1, + }, + }); + }, + }; + fetchStub.resolves({ + ok: true, + status: 304, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, messages.length); + messages.forEach(message => { + assert.ok(result.messages.find(m => m.id === message.id)); + }); + }); + + it("should return an empty array if json doesn't parse properly", async () => { + fetchStub.resolves({ + ok: false, + status: 200, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should report response parsing errors with MessageLoaderUtils.reportError", async () => { + const err = {}; + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: true, + status: 200, + json: sandbox.stub().rejects(err), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report that json parsing failed + assert.calledWith(MessageLoaderUtils.reportError, err); + }); + + it("should report missing `messages` with MessageLoaderUtils.reportError", async () => { + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: true, + status: 200, + json: sandbox.stub().resolves({}), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report no messages returned + assert.calledWith( + MessageLoaderUtils.reportError, + "No messages returned from https://foo.com." + ); + }); + + it("should report bad status responses with MessageLoaderUtils.reportError", async () => { + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: false, + status: 500, + json: sandbox.stub().resolves({}), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report no messages returned + assert.calledWith( + MessageLoaderUtils.reportError, + "Invalid response status 500 from https://foo.com." + ); + }); + + it("should return an empty array if the request rejects", async () => { + fetchStub.rejects(new Error("something went wrong")); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + }); + describe("remote provider caching", () => { + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + updateCycleInMs: 300, + }; + + it("should return cached results if they aren't expired", async () => { + clock.tick(1); + const messages = [{ id: "message-1" }, { id: "message-2" }]; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages, + etag: "etag0987654321", + lastFetched: Date.now(), + }, + }); + }, + }; + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, messages.length); + messages.forEach(message => { + assert.ok(result.messages.find(m => m.id === message.id)); + }); + }); + + it("should return fetch results if the cache messages are expired", async () => { + clock.tick(302); + const testMessage = { id: "foo" }; + const respJson = { messages: [testMessage] }; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages: [{ id: "message-1" }, { id: "message-2" }], + etag: "etag0987654321", + lastFetched: 1, + }, + }); + }, + }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].id, testMessage.id); + }); + }); + it("should return an empty array for a remote provider with a blank URL without attempting a request", async () => { + const provider = { id: "provider123", type: "remote", url: "" }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.notCalled(fetchStub); + assert.deepEqual(result.messages, []); + }); + it("should return .lastUpdated with the time at which the messages were fetched", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "remote", + url: "foo.com", + }; + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + new Promise(resolve => { + clock.tick(42); + resolve({ messages: [sourceMessage] }); + }), + headers: FAKE_RESPONSE_HEADERS, + }); + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.propertyVal(result, "lastUpdated", 42); + }); + }); + + describe("#shouldProviderUpdate", () => { + it("should return true if the provider does not had a .lastUpdated property", () => { + assert.isTrue(MessageLoaderUtils.shouldProviderUpdate({ id: "foo" })); + }); + it("should return false if the provider does not had a .updateCycleInMs property and has a .lastUpdated", () => { + clock.tick(1); + assert.isFalse( + MessageLoaderUtils.shouldProviderUpdate({ id: "foo", lastUpdated: 0 }) + ); + }); + it("should return true if the time since .lastUpdated is greater than .updateCycleInMs", () => { + clock.tick(301); + assert.isTrue( + MessageLoaderUtils.shouldProviderUpdate({ + id: "foo", + lastUpdated: 0, + updateCycleInMs: 300, + }) + ); + }); + it("should return false if the time since .lastUpdated is less than .updateCycleInMs", () => { + clock.tick(299); + assert.isFalse( + MessageLoaderUtils.shouldProviderUpdate({ + id: "foo", + lastUpdated: 0, + updateCycleInMs: 300, + }) + ); + }); + }); + + describe("#cleanupCache", () => { + it("should remove data for providers no longer active", async () => { + const fakeStorage = { + get: sinon.stub().returns( + Promise.resolve({ + "id-1": {}, + "id-2": {}, + "id-3": {}, + }) + ), + set: sinon.stub().returns(Promise.resolve()), + }; + const fakeProviders = [ + { id: "id-1", type: "remote" }, + { id: "id-3", type: "remote" }, + ]; + + await MessageLoaderUtils.cleanupCache(fakeProviders, fakeStorage); + + assert.calledOnce(fakeStorage.set); + assert.calledWith( + fakeStorage.set, + MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, + { "id-1": {}, "id-3": {} } + ); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx b/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx new file mode 100644 index 0000000000..2320e16fc3 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ModalOverlay.test.jsx @@ -0,0 +1,69 @@ +import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay"; +import { mount } from "enzyme"; +import React from "react"; + +describe("ModalOverlayWrapper", () => { + let fakeDoc; + let sandbox; + let header; + beforeEach(() => { + sandbox = sinon.createSandbox(); + header = document.createElement("div"); + + fakeDoc = { + addEventListener: sandbox.stub(), + removeEventListener: sandbox.stub(), + body: { classList: { add: sandbox.stub(), remove: sandbox.stub() } }, + getElementById() { + return header; + }, + }; + }); + afterEach(() => { + sandbox.restore(); + }); + it("should add eventListener and a class on mount", async () => { + mount(<ModalOverlayWrapper document={fakeDoc} />); + assert.calledOnce(fakeDoc.addEventListener); + assert.calledWith(fakeDoc.body.classList.add, "modal-open"); + }); + + it("should remove eventListener on unmount", async () => { + const wrapper = mount(<ModalOverlayWrapper document={fakeDoc} />); + wrapper.unmount(); + assert.calledOnce(fakeDoc.addEventListener); + assert.calledOnce(fakeDoc.removeEventListener); + assert.calledWith(fakeDoc.body.classList.remove, "modal-open"); + }); + + it("should call props.onClose on an Escape key", async () => { + const onClose = sandbox.stub(); + mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />); + + // Simulate onkeydown being called + const [, callback] = fakeDoc.addEventListener.firstCall.args; + callback({ key: "Escape" }); + + assert.calledOnce(onClose); + }); + + it("should not call props.onClose on other keys than Escape", async () => { + const onClose = sandbox.stub(); + mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />); + + // Simulate onkeydown being called + const [, callback] = fakeDoc.addEventListener.firstCall.args; + callback({ key: "Ctrl" }); + + assert.notCalled(onClose); + }); + + it("should not call props.onClose when clicked outside dialog", async () => { + const onClose = sandbox.stub(); + const wrapper = mount( + <ModalOverlayWrapper document={fakeDoc} onClose={onClose} /> + ); + wrapper.find("div.modalOverlayOuter.active").simulate("click"); + assert.notCalled(onClose); + }); +}); diff --git a/browser/components/asrouter/tests/unit/MomentsPageHub.test.js b/browser/components/asrouter/tests/unit/MomentsPageHub.test.js new file mode 100644 index 0000000000..63683a6849 --- /dev/null +++ b/browser/components/asrouter/tests/unit/MomentsPageHub.test.js @@ -0,0 +1,336 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; +import { _MomentsPageHub } from "modules/MomentsPageHub.sys.mjs"; +const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once"; + +describe("MomentsPageHub", () => { + let globals; + let sandbox; + let instance; + let handleMessageRequestStub; + let addImpressionStub; + let blockMessageByIdStub; + let sendTelemetryStub; + let getStringPrefStub; + let setStringPrefStub; + let setIntervalStub; + let clearIntervalStub; + + beforeEach(async () => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + instance = new _MomentsPageHub(); + const messages = (await PanelTestProvider.getMessages()).filter( + ({ template }) => template === "update_action" + ); + handleMessageRequestStub = sandbox.stub().resolves(messages); + addImpressionStub = sandbox.stub(); + blockMessageByIdStub = sandbox.stub(); + getStringPrefStub = sandbox.stub(); + setStringPrefStub = sandbox.stub(); + setIntervalStub = sandbox.stub(); + clearIntervalStub = sandbox.stub(); + sendTelemetryStub = sandbox.stub(); + globals.set({ + setInterval: setIntervalStub, + clearInterval: clearIntervalStub, + Services: { + prefs: { + getStringPref: getStringPrefStub, + setStringPref: setStringPrefStub, + }, + telemetry: { + recordEvent: () => {}, + }, + }, + }); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should create an instance", async () => { + setIntervalStub.returns(42); + assert.ok(instance); + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + assert.equal(instance.state._intervalId, 42); + }); + + it("should init only once", async () => { + assert.notCalled(handleMessageRequestStub); + + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + + assert.calledOnce(handleMessageRequestStub); + + instance.uninit(); + + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + + assert.calledTwice(handleMessageRequestStub); + }); + + it("should uninit the instance", () => { + instance.uninit(); + assert.calledOnce(clearIntervalStub); + }); + + it("should setInterval for `checkHomepageOverridePref`", async () => { + await instance.init(sandbox.stub().resolves(), {}); + sandbox.stub(instance, "checkHomepageOverridePref"); + + assert.calledOnce(setIntervalStub); + assert.calledWithExactly(setIntervalStub, sinon.match.func, 5 * 60 * 1000); + + assert.notCalled(instance.checkHomepageOverridePref); + const [cb] = setIntervalStub.firstCall.args; + + cb(); + + assert.calledOnce(instance.checkHomepageOverridePref); + }); + + describe("#messageRequest", () => { + beforeEach(async () => { + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + sendTelemetry: sendTelemetryStub, + }); + }); + afterEach(() => { + instance.uninit(); + }); + it("should fetch a message with the provided trigger and template", async () => { + await instance.messageRequest({ + triggerId: "trigger", + template: "template", + }); + + assert.calledTwice(handleMessageRequestStub); + assert.calledWithExactly(handleMessageRequestStub, { + triggerId: "trigger", + template: "template", + returnAll: true, + }); + }); + it("shouldn't do anything if no message is provided", async () => { + // Reset the call from `instance.init` + setStringPrefStub.reset(); + handleMessageRequestStub.resolves([]); + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(setStringPrefStub); + }); + it("should record telemetry events", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + assert.calledOnce(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + }); + it("should record Reach event for the Moments page experiment", async () => { + const momentsMessages = (await PanelTestProvider.getMessages()).filter( + ({ template }) => template === "update_action" + ); + const messages = [ + { + forReachEvent: { sent: false }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ...momentsMessages, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + sandbox.spy(instance, "executeAction"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(global.Services.telemetry.recordEvent); + assert.calledOnce(instance.executeAction); + }); + it("should not record the Reach event if it's already sent", async () => { + const messages = [ + { + forReachEvent: { sent: true }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(global.Services.telemetry.recordEvent); + }); + it("should not trigger the action if it's only for the Reach event", async () => { + const messages = [ + { + forReachEvent: { sent: false }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + sandbox.spy(instance, "executeAction"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(global.Services.telemetry.recordEvent); + assert.notCalled(instance.executeAction); + }); + }); + describe("executeAction", () => { + beforeEach(async () => { + blockMessageByIdStub = sandbox.stub(); + await instance.init(sandbox.stub().resolves(), { + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + sendTelemetry: sendTelemetryStub, + }); + }); + it("should set HOMEPAGE_OVERRIDE_PREF on `moments-wnp` action", async () => { + const [msg] = await handleMessageRequestStub(); + sandbox.useFakeTimers(); + instance.executeAction(msg); + + assert.calledOnce(setStringPrefStub); + assert.calledWithExactly( + setStringPrefStub, + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ + message_id: msg.id, + url: msg.content.action.data.url, + expire: instance.getExpirationDate( + msg.content.action.data.expireDelta + ), + }) + ); + }); + it("should block after taking the action", async () => { + const [msg] = await handleMessageRequestStub(); + instance.executeAction(msg); + + assert.calledOnce(blockMessageByIdStub); + assert.calledWithExactly(blockMessageByIdStub, msg.id); + }); + it("should compute expire based on expireDelta", async () => { + sandbox.spy(instance, "getExpirationDate"); + + const [msg] = await handleMessageRequestStub(); + instance.executeAction(msg); + + assert.calledOnce(instance.getExpirationDate); + assert.calledWithExactly( + instance.getExpirationDate, + msg.content.action.data.expireDelta + ); + }); + it("should compute expire based on expireDelta", async () => { + sandbox.spy(instance, "getExpirationDate"); + + const [msg] = await handleMessageRequestStub(); + const msgWithExpire = { + ...msg, + content: { + ...msg.content, + action: { + ...msg.content.action, + data: { ...msg.content.action.data, expire: 41 }, + }, + }, + }; + instance.executeAction(msgWithExpire); + + assert.notCalled(instance.getExpirationDate); + assert.calledOnce(setStringPrefStub); + assert.calledWithExactly( + setStringPrefStub, + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ + message_id: msg.id, + url: msg.content.action.data.url, + expire: 41, + }) + ); + }); + it("should send user telemetry", async () => { + const [msg] = await handleMessageRequestStub(); + const sendUserEventTelemetrySpy = sandbox.spy( + instance, + "sendUserEventTelemetry" + ); + instance.executeAction(msg); + + assert.calledOnce(sendTelemetryStub); + assert.calledWithExactly(sendUserEventTelemetrySpy, msg); + assert.calledWithExactly(sendTelemetryStub, { + type: "MOMENTS_PAGE_TELEMETRY", + data: { + action: "moments_user_event", + bucket_id: "WNP_THANK_YOU", + event: "MOMENTS_PAGE_SET", + message_id: "WNP_THANK_YOU", + }, + }); + }); + }); + describe("#checkHomepageOverridePref", () => { + let messageRequestStub; + beforeEach(() => { + messageRequestStub = sandbox.stub(instance, "messageRequest"); + }); + it("should catch parse errors", () => { + getStringPrefStub.returns({}); + + instance.checkHomepageOverridePref(); + + assert.calledOnce(messageRequestStub); + assert.calledWithExactly(messageRequestStub, { + template: "update_action", + triggerId: "momentsUpdate", + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/RemoteL10n.test.js b/browser/components/asrouter/tests/unit/RemoteL10n.test.js new file mode 100644 index 0000000000..dd0f858750 --- /dev/null +++ b/browser/components/asrouter/tests/unit/RemoteL10n.test.js @@ -0,0 +1,217 @@ +import { RemoteL10n, _RemoteL10n } from "modules/RemoteL10n.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("RemoteL10n", () => { + let sandbox; + let globals; + let domL10nStub; + let l10nRegStub; + let l10nRegInstance; + let fileSourceStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + domL10nStub = sandbox.stub(); + l10nRegInstance = { + hasSource: sandbox.stub(), + registerSources: sandbox.stub(), + removeSources: sandbox.stub(), + }; + + fileSourceStub = sandbox.stub(); + l10nRegStub = { + getInstance: () => { + return l10nRegInstance; + }, + }; + globals.set("DOMLocalization", domL10nStub); + globals.set("L10nRegistry", l10nRegStub); + globals.set("L10nFileSource", fileSourceStub); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + describe("#RemoteL10n", () => { + it("should create a new instance", () => { + assert.ok(new _RemoteL10n()); + }); + it("should create a DOMLocalization instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.propertyVal(instance._createDOML10n(), "instance", true); + assert.calledOnce(domL10nStub); + }); + it("should create a new instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.ok(instance.l10n); + + instance.reloadL10n(); + + assert.ok(instance.l10n); + + assert.calledTwice(domL10nStub); + }); + it("should reuse the instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.ok(instance.l10n); + assert.ok(instance.l10n); + + assert.calledOnce(domL10nStub); + }); + }); + describe("#_createDOML10n", () => { + it("should load the remote Fluent file if USE_REMOTE_L10N_PREF is true", async () => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + l10nRegInstance.hasSource.returns(false); + RemoteL10n._createDOML10n(); + + assert.calledOnce(domL10nStub); + const { args } = domL10nStub.firstCall; + // The first arg is the resource array, + // the second one is false (use async), + // and the third one is the bundle generator. + assert.equal(args.length, 2); + assert.deepEqual(args[0], [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); + assert.isFalse(args[1]); + assert.calledOnce(l10nRegInstance.hasSource); + assert.calledOnce(l10nRegInstance.registerSources); + assert.notCalled(l10nRegInstance.removeSources); + }); + it("should load the local Fluent file if USE_REMOTE_L10N_PREF is false", () => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(false); + l10nRegInstance.hasSource.returns(true); + RemoteL10n._createDOML10n(); + + const { args } = domL10nStub.firstCall; + // The first arg is the resource array, + // the second one is false (use async), + // and the third one is null. + assert.equal(args.length, 2); + assert.deepEqual(args[0], [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); + assert.isFalse(args[1]); + assert.calledOnce(l10nRegInstance.hasSource); + assert.notCalled(l10nRegInstance.registerSources); + assert.calledOnce(l10nRegInstance.removeSources); + }); + }); + describe("#createElement", () => { + let doc; + let instance; + let setStringStub; + let elem; + beforeEach(() => { + elem = document.createElement("div"); + doc = { + createElement: sandbox.stub().returns(elem), + createElementNS: sandbox.stub().returns(elem), + }; + instance = new _RemoteL10n(); + setStringStub = sandbox.stub(instance, "setString"); + }); + it("should call createElement if string_id is defined", () => { + instance.createElement(doc, "span", { content: { string_id: "foo" } }); + + assert.calledOnce(doc.createElement); + }); + it("should call createElementNS if string_id is not present", () => { + instance.createElement(doc, "span", { content: "foo" }); + + assert.calledOnce(doc.createElementNS); + }); + it("should set classList", () => { + instance.createElement(doc, "span", { classList: "foo" }); + + assert.isTrue(elem.classList.contains("foo")); + }); + it("should call setString", () => { + const options = { classList: "foo" }; + instance.createElement(doc, "span", options); + + assert.calledOnce(setStringStub); + assert.calledWithExactly(setStringStub, elem, options); + }); + }); + describe("#setString", () => { + let instance; + beforeEach(() => { + instance = new _RemoteL10n(); + }); + it("should set fluent variables and id", () => { + let el = { setAttribute: sandbox.stub() }; + instance.setString(el, { + content: { string_id: "foo" }, + attributes: { bar: "bar", baz: "baz" }, + }); + + assert.calledThrice(el.setAttribute); + assert.calledWithExactly(el.setAttribute, "fluent-variable-bar", "bar"); + assert.calledWithExactly(el.setAttribute, "fluent-variable-baz", "baz"); + assert.calledWithExactly(el.setAttribute, "fluent-remote-id", "foo"); + }); + it("should set content if no string_id", () => { + let el = { setAttribute: sandbox.stub() }; + instance.setString(el, { content: "foo" }); + + assert.notCalled(el.setAttribute); + assert.equal(el.textContent, "foo"); + }); + }); + describe("#isLocaleSupported", () => { + it("should return true if the locale is en-US", () => { + assert.ok(RemoteL10n.isLocaleSupported("en-US")); + }); + it("should return true if the locale is in all-locales", () => { + assert.ok(RemoteL10n.isLocaleSupported("en-CA")); + }); + it("should return false if the locale is not in all-locales", () => { + assert.ok(!RemoteL10n.isLocaleSupported("und")); + }); + }); + describe("#formatLocalizableText", () => { + let instance; + let formatValueStub; + beforeEach(() => { + instance = new _RemoteL10n(); + formatValueStub = sandbox.stub(); + sandbox + .stub(instance, "l10n") + .get(() => ({ formatValue: formatValueStub })); + }); + it("should localize a string_id", async () => { + formatValueStub.resolves("VALUE"); + + assert.equal( + await instance.formatLocalizableText({ string_id: "ID" }), + "VALUE" + ); + assert.calledOnce(formatValueStub); + }); + it("should pass through a string", async () => { + formatValueStub.reset(); + + assert.equal( + await instance.formatLocalizableText("unchanged"), + "unchanged" + ); + assert.isFalse(formatValueStub.called); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/TargetingDocs.test.js b/browser/components/asrouter/tests/unit/TargetingDocs.test.js new file mode 100644 index 0000000000..d00f971453 --- /dev/null +++ b/browser/components/asrouter/tests/unit/TargetingDocs.test.js @@ -0,0 +1,88 @@ +import { ASRouterTargeting } from "modules/ASRouterTargeting.sys.mjs"; +import docs from "docs/targeting-attributes.md"; + +// The following targeting parameters are either deprecated or should not be included in the docs for some reason. +const SKIP_DOCS = []; +// These are extra message context attributes via ASRouter.sys.mjs +const MESSAGE_CONTEXT_ATTRIBUTES = ["previousSessionEnd"]; + +function getHeadingsFromDocs() { + const re = /### `(\w+)`/g; + const found = []; + let match = 1; + while (match) { + match = re.exec(docs); + if (match) { + found.push(match[1]); + } + } + return found; +} + +function getTOCFromDocs() { + const re = /## Available attributes\n+([^]+)\n+## Detailed usage/; + const sectionMatch = docs.match(re); + if (!sectionMatch) { + return []; + } + const [, listText] = sectionMatch; + const re2 = /\[(\w+)\]/g; + const found = []; + let match = 1; + while (match) { + match = re2.exec(listText); + if (match) { + found.push(match[1]); + } + } + return found; +} + +describe("ASRTargeting docs", () => { + const DOCS_TARGETING_HEADINGS = getHeadingsFromDocs(); + const DOCS_TOC = getTOCFromDocs(); + const ASRTargetingAttributes = [ + ...Object.keys(ASRouterTargeting.Environment).filter( + attribute => !SKIP_DOCS.includes(attribute) + ), + ...MESSAGE_CONTEXT_ATTRIBUTES, + ]; + + describe("All targeting params documented in targeting-attributes.md", () => { + for (const targetingParam of ASRTargetingAttributes) { + // If this test is failing, you probably forgot to add docs to content-src/asrouter/targeting-attributes.md + // for a new targeting attribute, or you forgot to put it in the table of contents up top. + it(`should have docs and table of contents entry for ${targetingParam}`, () => { + assert.include( + DOCS_TARGETING_HEADINGS, + targetingParam, + `Didn't find the heading: ### \`${targetingParam}\`` + ); + assert.include( + DOCS_TOC, + targetingParam, + `Didn't find a table of contents entry for ${targetingParam}` + ); + }); + } + }); + describe("No extra attributes in targeting-attributes.md", () => { + // "allow" includes targeting attributes that are not implemented by + // ASRTargetingAttributes. For example trigger context passed to the evaluation + // context in when a trigger runs or ASRouter state used in the evaluation. + const allow = ["messageImpressions", "screenImpressions"]; + for (const targetingParam of DOCS_TARGETING_HEADINGS.filter( + doc => !allow.includes(doc) + )) { + // If this test is failing, you might have spelled something wrong or removed a targeting param without + // removing its docs. + it(`should have an implementation for ${targetingParam} in ASRouterTargeting.Environment`, () => { + assert.include( + ASRTargetingAttributes, + targetingParam, + `Didn't find an implementation for ${targetingParam}` + ); + }); + } + }); +}); diff --git a/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js new file mode 100644 index 0000000000..3e91b657bc --- /dev/null +++ b/browser/components/asrouter/tests/unit/ToolbarBadgeHub.test.js @@ -0,0 +1,652 @@ +import { _ToolbarBadgeHub } from "modules/ToolbarBadgeHub.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs"; +import { + _ToolbarPanelHub, + ToolbarPanelHub, +} from "modules/ToolbarPanelHub.sys.mjs"; + +describe("ToolbarBadgeHub", () => { + let sandbox; + let instance; + let fakeAddImpression; + let fakeSendTelemetry; + let isBrowserPrivateStub; + let fxaMessage; + let whatsnewMessage; + let fakeElement; + let globals; + let everyWindowStub; + let clearTimeoutStub; + let setTimeoutStub; + let addObserverStub; + let removeObserverStub; + let getStringPrefStub; + let clearUserPrefStub; + let setStringPrefStub; + let requestIdleCallbackStub; + let fakeWindow; + beforeEach(async () => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + instance = new _ToolbarBadgeHub(); + fakeAddImpression = sandbox.stub(); + fakeSendTelemetry = sandbox.stub(); + isBrowserPrivateStub = sandbox.stub(); + const onboardingMsgs = + await OnboardingMessageProvider.getUntranslatedMessages(); + fxaMessage = onboardingMsgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE"); + whatsnewMessage = { + id: `WHATS_NEW_BADGE_71`, + template: "toolbar_badge", + content: { + delay: 1000, + target: "whats-new-menu-button", + action: { id: "show-whatsnew-button" }, + badgeDescription: { string_id: "cfr-badge-reader-label-newfeature" }, + }, + priority: 1, + trigger: { id: "toolbarBadgeUpdate" }, + frequency: { + // Makes it so that we track impressions for this message while at the + // same time it can have unlimited impressions + lifetime: Infinity, + }, + // Never saw this message or saw it in the past 4 days or more recent + targeting: `isWhatsNewPanelEnabled && + (!messageImpressions['WHATS_NEW_BADGE_71'] || + (messageImpressions['WHATS_NEW_BADGE_71']|length >= 1 && + currentDate|date - messageImpressions['WHATS_NEW_BADGE_71'][0] <= 4 * 24 * 3600 * 1000))`, + }; + fakeElement = { + classList: { + add: sandbox.stub(), + remove: sandbox.stub(), + }, + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub(), + addEventListener: sandbox.stub(), + remove: sandbox.stub(), + appendChild: sandbox.stub(), + }; + // Share the same element when selecting child nodes + fakeElement.querySelector.returns(fakeElement); + everyWindowStub = { + registerCallback: sandbox.stub(), + unregisterCallback: sandbox.stub(), + }; + clearTimeoutStub = sandbox.stub(); + setTimeoutStub = sandbox.stub(); + fakeWindow = { + MozXULElement: { insertFTLIfNeeded: sandbox.stub() }, + ownerGlobal: { + gBrowser: { + selectedBrowser: "browser", + }, + }, + }; + addObserverStub = sandbox.stub(); + removeObserverStub = sandbox.stub(); + getStringPrefStub = sandbox.stub(); + clearUserPrefStub = sandbox.stub(); + setStringPrefStub = sandbox.stub(); + requestIdleCallbackStub = sandbox.stub().callsFake(fn => fn()); + globals.set({ + ToolbarPanelHub, + requestIdleCallback: requestIdleCallbackStub, + EveryWindow: everyWindowStub, + PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub }, + setTimeout: setTimeoutStub, + clearTimeout: clearTimeoutStub, + Services: { + wm: { + getMostRecentWindow: () => fakeWindow, + }, + prefs: { + addObserver: addObserverStub, + removeObserver: removeObserverStub, + getStringPref: getStringPrefStub, + clearUserPref: clearUserPrefStub, + setStringPref: setStringPrefStub, + }, + }, + }); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + it("should create an instance", () => { + assert.ok(instance); + }); + describe("#init", () => { + it("should make a single messageRequest on init", async () => { + sandbox.stub(instance, "messageRequest"); + const waitForInitialized = sandbox.stub().resolves(); + + await instance.init(waitForInitialized, {}); + await instance.init(waitForInitialized, {}); + assert.calledOnce(instance.messageRequest); + assert.calledWithExactly(instance.messageRequest, { + template: "toolbar_badge", + triggerId: "toolbarBadgeUpdate", + }); + + instance.uninit(); + + await instance.init(waitForInitialized, {}); + + assert.calledTwice(instance.messageRequest); + }); + it("should add a pref observer", async () => { + await instance.init(sandbox.stub().resolves(), {}); + + assert.calledOnce(addObserverStub); + assert.calledWithExactly( + addObserverStub, + instance.prefs.WHATSNEW_TOOLBAR_PANEL, + instance + ); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), {}); + }); + it("should clear any setTimeout cbs", async () => { + await instance.init(sandbox.stub().resolves(), {}); + + instance.state.showBadgeTimeoutId = 2; + + instance.uninit(); + + assert.calledOnce(clearTimeoutStub); + assert.calledWithExactly(clearTimeoutStub, 2); + }); + it("should remove the pref observer", () => { + instance.uninit(); + + assert.calledOnce(removeObserverStub); + assert.calledWithExactly( + removeObserverStub, + instance.prefs.WHATSNEW_TOOLBAR_PANEL, + instance + ); + }); + }); + describe("messageRequest", () => { + let handleMessageRequestStub; + beforeEach(() => { + handleMessageRequestStub = sandbox.stub().returns(fxaMessage); + sandbox + .stub(instance, "_handleMessageRequest") + .value(handleMessageRequestStub); + sandbox.stub(instance, "registerBadgeNotificationListener"); + }); + it("should fetch a message with the provided trigger and template", async () => { + await instance.messageRequest({ + triggerId: "trigger", + template: "template", + }); + + assert.calledOnce(handleMessageRequestStub); + assert.calledWithExactly(handleMessageRequestStub, { + triggerId: "trigger", + template: "template", + }); + }); + it("should call addToolbarNotification with browser window and message", async () => { + await instance.messageRequest("trigger"); + + assert.calledOnce(instance.registerBadgeNotificationListener); + assert.calledWithExactly( + instance.registerBadgeNotificationListener, + fxaMessage + ); + }); + it("shouldn't do anything if no message is provided", async () => { + handleMessageRequestStub.resolves(null); + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(instance.registerBadgeNotificationListener); + }); + it("should record telemetry events", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + handleMessageRequestStub.returns(null); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + assert.calledOnce(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + }); + }); + describe("addToolbarNotification", () => { + let target; + let fakeDocument; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + sendTelemetry: fakeSendTelemetry, + }); + fakeDocument = { + getElementById: sandbox.stub().returns(fakeElement), + createElement: sandbox.stub().returns(fakeElement), + l10n: { setAttributes: sandbox.stub() }, + }; + target = { ...fakeWindow, browser: { ownerDocument: fakeDocument } }; + }); + afterEach(() => { + instance.uninit(); + }); + it("shouldn't do anything if target element is not found", () => { + fakeDocument.getElementById.returns(null); + instance.addToolbarNotification(target, fxaMessage); + + assert.notCalled(fakeElement.setAttribute); + }); + it("should target the element specified in the message", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledOnce(fakeDocument.getElementById); + assert.calledWithExactly( + fakeDocument.getElementById, + fxaMessage.content.target + ); + }); + it("should show a notification", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledOnce(fakeElement.setAttribute); + assert.calledWithExactly(fakeElement.setAttribute, "badged", true); + assert.calledWithExactly(fakeElement.classList.add, "feature-callout"); + }); + it("should attach a cb on the notification", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledTwice(fakeElement.addEventListener); + assert.calledWithExactly( + fakeElement.addEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeElement.addEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + it("should execute actions if they exist", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance.executeAction); + assert.calledWithExactly(instance.executeAction, { + ...whatsnewMessage.content.action, + message_id: whatsnewMessage.id, + }); + }); + it("should create a description element", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(fakeDocument.createElement); + assert.calledWithExactly(fakeDocument.createElement, "span"); + }); + it("should set description id to element and to button", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledWithExactly( + fakeElement.setAttribute, + "id", + "toolbarbutton-notification-description" + ); + assert.calledWithExactly( + fakeElement.setAttribute, + "aria-labelledby", + `toolbarbutton-notification-description ${whatsnewMessage.content.target}` + ); + }); + it("should attach fluent id to description", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(fakeDocument.l10n.setAttributes); + assert.calledWithExactly( + fakeDocument.l10n.setAttributes, + fakeElement, + whatsnewMessage.content.badgeDescription.string_id + ); + }); + it("should add an impression for the message", () => { + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance._addImpression); + assert.calledWithExactly(instance._addImpression, whatsnewMessage); + }); + it("should send an impression ping", async () => { + sandbox.stub(instance, "sendUserEventTelemetry"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance.sendUserEventTelemetry); + assert.calledWithExactly( + instance.sendUserEventTelemetry, + "IMPRESSION", + whatsnewMessage + ); + }); + }); + describe("registerBadgeNotificationListener", () => { + let msg_no_delay; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + sendTelemetry: fakeSendTelemetry, + }); + sandbox.stub(instance, "addToolbarNotification").returns(fakeElement); + sandbox.stub(instance, "removeToolbarNotification"); + msg_no_delay = { + ...fxaMessage, + content: { + ...fxaMessage.content, + delay: 0, + }, + }; + }); + afterEach(() => { + instance.uninit(); + }); + it("should register a callback that adds/removes the notification", () => { + instance.registerBadgeNotificationListener(msg_no_delay); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + instance.id, + sinon.match.func, + sinon.match.func + ); + + const [, initFn, uninitFn] = + everyWindowStub.registerCallback.firstCall.args; + + initFn(window); + // Test that it doesn't try to add a second notification + initFn(window); + + assert.calledOnce(instance.addToolbarNotification); + assert.calledWithExactly( + instance.addToolbarNotification, + window, + msg_no_delay + ); + + uninitFn(window); + + assert.calledOnce(instance.removeToolbarNotification); + assert.calledWithExactly(instance.removeToolbarNotification, fakeElement); + }); + it("should unregister notifications when forcing a badge via devtools", () => { + instance.registerBadgeNotificationListener(msg_no_delay, { force: true }); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id); + }); + it("should only call executeAction for 'update_action' messages", () => { + const stub = sandbox.stub(instance, "executeAction"); + const updateActionMsg = { ...msg_no_delay, template: "update_action" }; + + instance.registerBadgeNotificationListener(updateActionMsg); + + assert.notCalled(everyWindowStub.registerCallback); + assert.calledOnce(stub); + }); + }); + describe("executeAction", () => { + let blockMessageByIdStub; + beforeEach(async () => { + blockMessageByIdStub = sandbox.stub(); + await instance.init(sandbox.stub().resolves(), { + blockMessageById: blockMessageByIdStub, + }); + }); + it("should call ToolbarPanelHub.enableToolbarButton", () => { + const stub = sandbox.stub( + _ToolbarPanelHub.prototype, + "enableToolbarButton" + ); + + instance.executeAction({ id: "show-whatsnew-button" }); + + assert.calledOnce(stub); + }); + it("should call ToolbarPanelHub.enableAppmenuButton", () => { + const stub = sandbox.stub( + _ToolbarPanelHub.prototype, + "enableAppmenuButton" + ); + + instance.executeAction({ id: "show-whatsnew-button" }); + + assert.calledOnce(stub); + }); + }); + describe("removeToolbarNotification", () => { + it("should remove the notification", () => { + instance.removeToolbarNotification(fakeElement); + + assert.calledThrice(fakeElement.removeAttribute); + assert.calledWithExactly(fakeElement.removeAttribute, "badged"); + assert.calledWithExactly(fakeElement.removeAttribute, "aria-labelledby"); + assert.calledWithExactly(fakeElement.removeAttribute, "aria-describedby"); + assert.calledOnce(fakeElement.classList.remove); + assert.calledWithExactly(fakeElement.classList.remove, "feature-callout"); + assert.calledOnce(fakeElement.remove); + }); + }); + describe("removeAllNotifications", () => { + let blockMessageByIdStub; + let fakeEvent; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + sendTelemetry: fakeSendTelemetry, + }); + blockMessageByIdStub = sandbox.stub(); + sandbox.stub(instance, "_blockMessageById").value(blockMessageByIdStub); + instance.state = { notification: { id: fxaMessage.id } }; + fakeEvent = { target: { removeEventListener: sandbox.stub() } }; + }); + it("should call to block the message", () => { + instance.removeAllNotifications(); + + assert.calledOnce(blockMessageByIdStub); + assert.calledWithExactly(blockMessageByIdStub, fxaMessage.id); + }); + it("should remove the window listener", () => { + instance.removeAllNotifications(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id); + }); + it("should ignore right mouse button (mousedown event)", () => { + fakeEvent.type = "mousedown"; + fakeEvent.button = 1; // not left click + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should ignore right mouse button (click event)", () => { + fakeEvent.type = "click"; + fakeEvent.button = 1; // not left click + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should ignore keypresses that are not meant to focus the target", () => { + fakeEvent.type = "keypress"; + fakeEvent.key = "\t"; // not enter + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should remove the event listeners after succesfully focusing the element", () => { + fakeEvent.type = "click"; + fakeEvent.button = 0; + + instance.removeAllNotifications(fakeEvent); + + assert.calledTwice(fakeEvent.target.removeEventListener); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + it("should send telemetry", () => { + fakeEvent.type = "click"; + fakeEvent.button = 0; + sandbox.stub(instance, "sendUserEventTelemetry"); + + instance.removeAllNotifications(fakeEvent); + + assert.calledOnce(instance.sendUserEventTelemetry); + assert.calledWithExactly(instance.sendUserEventTelemetry, "CLICK", { + id: "FXA_ACCOUNTS_BADGE", + }); + }); + it("should remove the event listeners after succesfully focusing the element", () => { + fakeEvent.type = "keypress"; + fakeEvent.key = "Enter"; + + instance.removeAllNotifications(fakeEvent); + + assert.calledTwice(fakeEvent.target.removeEventListener); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + }); + describe("message with delay", () => { + let msg_with_delay; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + }); + msg_with_delay = { + ...fxaMessage, + content: { + ...fxaMessage.content, + delay: 500, + }, + }; + sandbox.stub(instance, "registerBadgeToAllWindows"); + }); + afterEach(() => { + instance.uninit(); + }); + it("should register a cb to fire after msg.content.delay ms", () => { + instance.registerBadgeNotificationListener(msg_with_delay); + + assert.calledOnce(setTimeoutStub); + assert.calledWithExactly( + setTimeoutStub, + sinon.match.func, + msg_with_delay.content.delay + ); + + const [cb] = setTimeoutStub.firstCall.args; + + assert.notCalled(instance.registerBadgeToAllWindows); + + cb(); + + assert.calledOnce(instance.registerBadgeToAllWindows); + assert.calledWithExactly( + instance.registerBadgeToAllWindows, + msg_with_delay + ); + // Delayed actions should be executed inside requestIdleCallback + assert.calledOnce(requestIdleCallbackStub); + }); + }); + describe("#sendUserEventTelemetry", () => { + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + sendTelemetry: fakeSendTelemetry, + }); + }); + it("should check for private window and not send", () => { + isBrowserPrivateStub.returns(true); + + instance.sendUserEventTelemetry("CLICK", { id: fxaMessage }); + + assert.notCalled(instance._sendTelemetry); + }); + it("should check for private window and send", () => { + isBrowserPrivateStub.returns(false); + + instance.sendUserEventTelemetry("CLICK", { id: fxaMessage }); + + assert.calledOnce(fakeSendTelemetry); + const [ping] = instance._sendTelemetry.firstCall.args; + assert.propertyVal(ping, "type", "TOOLBAR_BADGE_TELEMETRY"); + assert.propertyVal(ping.data, "event", "CLICK"); + }); + }); + describe("#observe", () => { + it("should make a message request when the whats new pref is changed", () => { + sandbox.stub(instance, "messageRequest"); + + instance.observe("", "", instance.prefs.WHATSNEW_TOOLBAR_PANEL); + + assert.calledOnce(instance.messageRequest); + assert.calledWithExactly(instance.messageRequest, { + template: "toolbar_badge", + triggerId: "toolbarBadgeUpdate", + }); + }); + it("should not react to other pref changes", () => { + sandbox.stub(instance, "messageRequest"); + + instance.observe("", "", "foo"); + + assert.notCalled(instance.messageRequest); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js b/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js new file mode 100644 index 0000000000..64cb8243b7 --- /dev/null +++ b/browser/components/asrouter/tests/unit/ToolbarPanelHub.test.js @@ -0,0 +1,762 @@ +import { _ToolbarPanelHub } from "modules/ToolbarPanelHub.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "modules/PanelTestProvider.sys.mjs"; + +describe("ToolbarPanelHub", () => { + let globals; + let sandbox; + let instance; + let everyWindowStub; + let fakeDocument; + let fakeWindow; + let fakeElementById; + let fakeElementByTagName; + let createdCustomElements = []; + let eventListeners = {}; + let addObserverStub; + let removeObserverStub; + let getBoolPrefStub; + let setBoolPrefStub; + let waitForInitializedStub; + let isBrowserPrivateStub; + let fakeSendTelemetry; + let getEarliestRecordedDateStub; + let getEventsByDateRangeStub; + let defaultSearchStub; + let scriptloaderStub; + let fakeRemoteL10n; + let getViewNodeStub; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + instance = new _ToolbarPanelHub(); + waitForInitializedStub = sandbox.stub().resolves(); + fakeElementById = { + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub().returns(null), + querySelectorAll: sandbox.stub().returns([]), + appendChild: sandbox.stub(), + addEventListener: sandbox.stub(), + hasAttribute: sandbox.stub(), + toggleAttribute: sandbox.stub(), + remove: sandbox.stub(), + removeChild: sandbox.stub(), + }; + fakeElementByTagName = { + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub().returns(null), + querySelectorAll: sandbox.stub().returns([]), + appendChild: sandbox.stub(), + addEventListener: sandbox.stub(), + hasAttribute: sandbox.stub(), + toggleAttribute: sandbox.stub(), + remove: sandbox.stub(), + removeChild: sandbox.stub(), + }; + fakeDocument = { + getElementById: sandbox.stub().returns(fakeElementById), + getElementsByTagName: sandbox.stub().returns(fakeElementByTagName), + querySelector: sandbox.stub().returns({}), + createElement: tagName => { + const element = { + tagName, + classList: {}, + addEventListener: (ev, fn) => { + eventListeners[ev] = fn; + }, + appendChild: sandbox.stub(), + setAttribute: sandbox.stub(), + textContent: "", + }; + element.classList.add = sandbox.stub(); + element.classList.includes = className => + element.classList.add.firstCall.args[0] === className; + createdCustomElements.push(element); + return element; + }, + l10n: { + translateElements: sandbox.stub(), + translateFragment: sandbox.stub(), + formatMessages: sandbox.stub().resolves([{}]), + setAttributes: sandbox.stub(), + }, + }; + fakeWindow = { + // eslint-disable-next-line object-shorthand + DocumentFragment: function () { + return fakeElementById; + }, + document: fakeDocument, + browser: { + ownerDocument: fakeDocument, + }, + MozXULElement: { insertFTLIfNeeded: sandbox.stub() }, + ownerGlobal: { + openLinkIn: sandbox.stub(), + gBrowser: "gBrowser", + }, + PanelUI: { + panel: fakeElementById, + whatsNewPanel: fakeElementById, + }, + customElements: { get: sandbox.stub() }, + }; + everyWindowStub = { + registerCallback: sandbox.stub(), + unregisterCallback: sandbox.stub(), + }; + scriptloaderStub = { loadSubScript: sandbox.stub() }; + addObserverStub = sandbox.stub(); + removeObserverStub = sandbox.stub(); + getBoolPrefStub = sandbox.stub(); + setBoolPrefStub = sandbox.stub(); + fakeSendTelemetry = sandbox.stub(); + isBrowserPrivateStub = sandbox.stub(); + getEarliestRecordedDateStub = sandbox.stub().returns( + // A random date that's not the current timestamp + new Date() - 500 + ); + getEventsByDateRangeStub = sandbox.stub().returns([]); + getViewNodeStub = sandbox.stub().returns(fakeElementById); + defaultSearchStub = { defaultEngine: { name: "DDG" } }; + fakeRemoteL10n = { + l10n: {}, + reloadL10n: sandbox.stub(), + createElement: sandbox + .stub() + .callsFake((doc, el) => fakeDocument.createElement(el)), + }; + globals.set({ + EveryWindow: everyWindowStub, + Services: { + ...Services, + prefs: { + addObserver: addObserverStub, + removeObserver: removeObserverStub, + getBoolPref: getBoolPrefStub, + setBoolPref: setBoolPrefStub, + }, + search: defaultSearchStub, + scriptloader: scriptloaderStub, + }, + PrivateBrowsingUtils: { + isBrowserPrivate: isBrowserPrivateStub, + }, + TrackingDBService: { + getEarliestRecordedDate: getEarliestRecordedDateStub, + getEventsByDateRange: getEventsByDateRangeStub, + }, + SpecialMessageActions: { + handleAction: sandbox.stub(), + }, + RemoteL10n: fakeRemoteL10n, + PanelMultiView: { + getViewNode: getViewNodeStub, + }, + }); + }); + afterEach(() => { + instance.uninit(); + sandbox.restore(); + globals.restore(); + eventListeners = {}; + createdCustomElements = []; + }); + it("should create an instance", () => { + assert.ok(instance); + }); + it("should enableAppmenuButton() on init() just once", async () => { + instance.enableAppmenuButton = sandbox.stub(); + + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + + assert.calledOnce(instance.enableAppmenuButton); + + instance.uninit(); + + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + + assert.calledTwice(instance.enableAppmenuButton); + }); + it("should unregisterCallback on uninit()", () => { + instance.uninit(); + assert.calledTwice(everyWindowStub.unregisterCallback); + }); + describe("#maybeLoadCustomElement", () => { + it("should not load customElements a second time", () => { + instance.maybeLoadCustomElement({ customElements: new Map() }); + instance.maybeLoadCustomElement({ + customElements: new Map([["remote-text", true]]), + }); + + assert.calledOnce(scriptloaderStub.loadSubScript); + }); + }); + describe("#toggleWhatsNewPref", () => { + it("should call Services.prefs.setBoolPref() with the opposite value", () => { + let checkbox = {}; + let event = { target: checkbox }; + // checkbox starts false + checkbox.checked = false; + + // toggling the checkbox to set the value to true; + // Preferences.set() gets called before the checkbox changes, + // so we have to call it with the opposite value. + instance.toggleWhatsNewPref(event); + + assert.calledOnce(setBoolPrefStub); + assert.calledWith( + setBoolPrefStub, + "browser.messaging-system.whatsNewPanel.enabled", + !checkbox.checked + ); + }); + it("should report telemetry with the opposite value", () => { + let sendUserEventTelemetryStub = sandbox.stub( + instance, + "sendUserEventTelemetry" + ); + let event = { + target: { checked: true, ownerGlobal: fakeWindow }, + }; + + instance.toggleWhatsNewPref(event); + + assert.calledOnce(sendUserEventTelemetryStub); + const { args } = sendUserEventTelemetryStub.firstCall; + assert.equal(args[1], "WNP_PREF_TOGGLE"); + assert.propertyVal(args[3].value, "prefValue", false); + }); + }); + describe("#enableAppmenuButton", () => { + it("should registerCallback on enableAppmenuButton() if there are messages", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([{}, {}]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableAppmenuButton(); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + "appMenu-whatsnew-button", + sinon.match.func, + sinon.match.func + ); + }); + it("should not registerCallback on enableAppmenuButton() if there are no messages", async () => { + instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableAppmenuButton(); + + assert.notCalled(everyWindowStub.registerCallback); + }); + }); + describe("#disableAppmenuButton", () => { + it("should call the unregisterCallback", () => { + assert.notCalled(everyWindowStub.unregisterCallback); + + instance.disableAppmenuButton(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly( + everyWindowStub.unregisterCallback, + "appMenu-whatsnew-button" + ); + }); + }); + describe("#enableToolbarButton", () => { + it("should registerCallback on enableToolbarButton if messages.length", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([{}, {}]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableToolbarButton(); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + "whats-new-menu-button", + sinon.match.func, + sinon.match.func + ); + }); + it("should not registerCallback on enableToolbarButton if no messages", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([]), + }); + + await instance.enableToolbarButton(); + + assert.notCalled(everyWindowStub.registerCallback); + }); + }); + describe("Show/Hide functions", () => { + it("should unhide appmenu button on _showAppmenuButton()", async () => { + await instance._showAppmenuButton(fakeWindow); + + assert.equal(fakeElementById.hidden, false); + }); + it("should hide appmenu button on _hideAppmenuButton()", () => { + instance._hideAppmenuButton(fakeWindow); + assert.equal(fakeElementById.hidden, true); + }); + it("should not do anything if the window is closed", () => { + instance._hideAppmenuButton(fakeWindow, true); + assert.notCalled(global.PanelMultiView.getViewNode); + }); + it("should not throw if the element does not exist", () => { + let fn = instance._hideAppmenuButton.bind(null, { + browser: { ownerDocument: {} }, + }); + getViewNodeStub.returns(undefined); + assert.doesNotThrow(fn); + }); + it("should unhide toolbar button on _showToolbarButton()", async () => { + await instance._showToolbarButton(fakeWindow); + + assert.equal(fakeElementById.hidden, false); + }); + it("should hide toolbar button on _hideToolbarButton()", () => { + instance._hideToolbarButton(fakeWindow); + assert.equal(fakeElementById.hidden, true); + }); + }); + describe("#renderMessages", () => { + let getMessagesStub; + beforeEach(() => { + getMessagesStub = sandbox.stub(); + instance.init(waitForInitializedStub, { + getMessages: getMessagesStub, + sendTelemetry: fakeSendTelemetry, + }); + }); + it("should have correct state", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + + getMessagesStub.returns(messages); + const ev1 = sandbox.stub(); + ev1.withArgs("type").returns(1); // tracker + ev1.withArgs("count").returns(4); + const ev2 = sandbox.stub(); + ev2.withArgs("type").returns(4); // fingerprinter + ev2.withArgs("count").returns(3); + getEventsByDateRangeStub.returns([ + { getResultByName: ev1 }, + { getResultByName: ev2 }, + ]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.propertyVal(instance.state.contentArguments, "trackerCount", 4); + assert.propertyVal( + instance.state.contentArguments, + "fingerprinterCount", + 3 + ); + }); + it("should render messages to the panel on renderMessages()", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + messages[0].content.link_text = { string_id: "link_text_id" }; + + getMessagesStub.returns(messages); + const ev1 = sandbox.stub(); + ev1.withArgs("type").returns(1); // tracker + ev1.withArgs("count").returns(4); + const ev2 = sandbox.stub(); + ev2.withArgs("type").returns(4); // fingerprinter + ev2.withArgs("count").returns(3); + getEventsByDateRangeStub.returns([ + { getResultByName: ev1 }, + { getResultByName: ev2 }, + ]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + for (let message of messages) { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-title" + ) + ); + if (message.content.layout === "tracking-protections") { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-subtitle" + ) + ); + } + if (message.id === "WHATS_NEW_FINGERPRINTER_COUNTER_72") { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => el === "h2" && args.content === 3 + ) + ); + } + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-content" + ) + ); + } + // Call the click handler to make coverage happy. + eventListeners.mouseup(); + assert.calledOnce(global.SpecialMessageActions.handleAction); + }); + it("should clear previous messages on 2nd renderMessages()", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + const removeStub = sandbox.stub(); + fakeElementById.querySelectorAll.onCall(0).returns([]); + fakeElementById.querySelectorAll + .onCall(1) + .returns([{ remove: removeStub }, { remove: removeStub }]); + + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledTwice(removeStub); + }); + it("should sort based on order field value", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => + m.template === "whatsnew_panel_message" && + m.content.published_date === 1560969794394 + ); + + messages.forEach(m => (m.content.title = m.order)); + + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + // Select the title elements that are supposed to be set to the same + // value as the `order` field of the message + const titleEls = fakeRemoteL10n.createElement.args + .filter( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-title" + ) + .map(([doc, el, args]) => args.content); + assert.deepEqual(titleEls, [1, 2, 3]); + }); + it("should accept string for image attributes", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.id === "WHATS_NEW_70_1" + ); + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const imageEl = createdCustomElements.find(el => el.tagName === "img"); + assert.calledOnce(imageEl.setAttribute); + assert.calledWithExactly( + imageEl.setAttribute, + "alt", + "Firefox Send Logo" + ); + }); + it("should set state values as data-attribute", async () => { + const message = (await PanelTestProvider.getMessages()).find( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns([message]); + instance.state.contentArguments = { foo: "foo", bar: "bar" }; + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const [, , args] = fakeRemoteL10n.createElement.args.find( + ([doc, el, elArgs]) => elArgs && elArgs.attributes + ); + assert.ok(args); + // Currently this.state.contentArguments has 8 different entries + assert.lengthOf(Object.keys(args.attributes), 8); + assert.equal( + args.attributes.searchEngineName, + defaultSearchStub.defaultEngine.name + ); + }); + it("should only render unique dates (no duplicates)", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + const uniqueDates = [ + ...new Set(messages.map(m => m.content.published_date)), + ]; + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const dateElements = fakeRemoteL10n.createElement.args.filter( + ([doc, el, args]) => + el === "p" && args.classList === "whatsNew-message-date" + ); + assert.lengthOf(dateElements, uniqueDates.length); + }); + it("should listen for panelhidden and remove the toolbar button", async () => { + getMessagesStub.returns([]); + fakeDocument.getElementById + .withArgs("customizationui-widget-panel") + .returns(null); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.notCalled(fakeElementById.addEventListener); + }); + it("should attach doCommand cbs that handle user actions", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const messageEl = createdCustomElements.find( + el => + el.tagName === "div" && el.classList.includes("whatsNew-message-body") + ); + const anchorEl = createdCustomElements.find(el => el.tagName === "a"); + + assert.notCalled(global.SpecialMessageActions.handleAction); + + messageEl.doCommand(); + anchorEl.doCommand(); + + assert.calledTwice(global.SpecialMessageActions.handleAction); + }); + it("should listen for panelhidden and remove the toolbar button", async () => { + getMessagesStub.returns([]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(fakeElementById.addEventListener); + assert.calledWithExactly( + fakeElementById.addEventListener, + "popuphidden", + sinon.match.func, + { + once: true, + } + ); + const [, cb] = fakeElementById.addEventListener.firstCall.args; + + assert.notCalled(everyWindowStub.unregisterCallback); + + cb(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly( + everyWindowStub.unregisterCallback, + "whats-new-menu-button" + ); + }); + describe("#IMPRESSION", () => { + it("should dispatch a IMPRESSION for messages", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledOnce(fakeSendTelemetry); + assert.propertyVal( + spy.firstCall.args[2], + "id", + messages + .map(({ id }) => id) + .sort() + .join(",") + ); + }); + it("should dispatch a CLICK for clicking a message", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + // Force to render the message + fakeElementById.querySelector.returns(null); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns([messages[0]]); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledOnce(fakeSendTelemetry); + + spy.resetHistory(); + + // Message click event listener cb + eventListeners.mouseup(); + + assert.calledOnce(spy); + assert.calledWithExactly(spy, fakeWindow, "CLICK", messages[0]); + }); + it("should dispatch a IMPRESSION with toolbar_dropdown", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.resolves(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + const panelPingId = messages + .map(({ id }) => id) + .sort() + .join(","); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledWithExactly( + spy, + fakeWindow, + "IMPRESSION", + { + id: panelPingId, + }, + { + value: { + view: "toolbar_dropdown", + }, + } + ); + assert.calledOnce(fakeSendTelemetry); + const { + args: [dispatchPayload], + } = fakeSendTelemetry.lastCall; + assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); + assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); + assert.deepEqual(dispatchPayload.data.event_context, { + view: "toolbar_dropdown", + }); + }); + it("should dispatch a IMPRESSION with application_menu", async () => { + // means panel is triggered as a subview in the application menu + fakeElementById.hasAttribute.returns(false); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.resolves(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + const panelPingId = messages + .map(({ id }) => id) + .sort() + .join(","); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledWithExactly( + spy, + fakeWindow, + "IMPRESSION", + { + id: panelPingId, + }, + { + value: { + view: "application_menu", + }, + } + ); + assert.calledOnce(fakeSendTelemetry); + const { + args: [dispatchPayload], + } = fakeSendTelemetry.lastCall; + assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); + assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); + assert.deepEqual(dispatchPayload.data.event_context, { + view: "application_menu", + }); + }); + }); + describe("#forceShowMessage", () => { + const panelSelector = "PanelUI-whatsNew-message-container"; + let removeMessagesSpy; + let renderMessagesStub; + let addEventListenerStub; + let messages; + let browser; + beforeEach(async () => { + messages = (await PanelTestProvider.getMessages()).find( + m => m.id === "WHATS_NEW_70_1" + ); + removeMessagesSpy = sandbox.spy(instance, "removeMessages"); + renderMessagesStub = sandbox.spy(instance, "renderMessages"); + addEventListenerStub = fakeElementById.addEventListener; + browser = { ownerGlobal: fakeWindow, ownerDocument: fakeDocument }; + fakeElementById.querySelectorAll.returns([fakeElementById]); + }); + it("should call removeMessages when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); + }); + it("should call renderMessages when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledOnce(renderMessagesStub); + assert.calledWithExactly( + renderMessagesStub, + fakeWindow, + fakeDocument, + panelSelector, + { + force: true, + messages: Array.isArray(messages) ? messages : [messages], + } + ); + }); + it("should cleanup after the panel is hidden when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledOnce(addEventListenerStub); + assert.calledWithExactly( + addEventListenerStub, + "popuphidden", + sinon.match.func + ); + + const [, cb] = addEventListenerStub.firstCall.args; + // Reset the call count from the first `forceShowMessage` call + removeMessagesSpy.resetHistory(); + cb({ target: { ownerGlobal: fakeWindow } }); + + assert.calledOnce(removeMessagesSpy); + assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); + }); + it("should exit gracefully if called before a browser exists", () => { + instance.forceShowMessage(null, messages); + assert.neverCalledWith(removeMessagesSpy, fakeWindow, panelSelector); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/asrouter-utils.test.js b/browser/components/asrouter/tests/unit/asrouter-utils.test.js new file mode 100644 index 0000000000..553c9608ed --- /dev/null +++ b/browser/components/asrouter/tests/unit/asrouter-utils.test.js @@ -0,0 +1,118 @@ +import { ASRouterUtils } from "content-src/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterUtils", () => { + let globals = null; + let overrider = null; + let sandbox = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = { + ASRouterMessage: sandbox.stub().resolves({}), + }; + overrider = new GlobalOverrider(); + overrider.set(globals); + }); + afterEach(() => { + sandbox.restore(); + overrider.restore(); + }); + describe("sendMessage", () => { + it("default", () => { + ASRouterUtils.sendMessage({ foo: "bar" }); + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { foo: "bar" }); + }); + it("throws if ASRouterMessage is not defined", () => { + overrider.set("ASRouterMessage", undefined); + assert.throws(() => ASRouterUtils.sendMessage({ foo: "bar" })); + }); + it("can accept the legacy NEWTAB_MESSAGE_REQUEST message without throwing", async () => { + assert.doesNotThrow(async () => { + let result = await ASRouterUtils.sendMessage({ + type: "NEWTAB_MESSAGE_REQUEST", + data: {}, + }); + sandbox.assert.deepEqual(result, {}); + }); + }); + }); + describe("blockById", () => { + it("default", () => { + ASRouterUtils.blockById(1, { foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar", id: 1 } }) + ); + }); + }); + describe("modifyMessageJson", () => { + it("default", () => { + ASRouterUtils.modifyMessageJson({ foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { content: { foo: "bar" } } }) + ); + }); + }); + describe("executeAction", () => { + it("default", () => { + ASRouterUtils.executeAction({ foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar" } }) + ); + }); + }); + describe("unblockById", () => { + it("default", () => { + ASRouterUtils.unblockById(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { id: 2 } }) + ); + }); + }); + describe("blockBundle", () => { + it("default", () => { + ASRouterUtils.blockBundle(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { bundle: 2 } }) + ); + }); + }); + describe("unblockBundle", () => { + it("default", () => { + ASRouterUtils.unblockBundle(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { bundle: 2 } }) + ); + }); + }); + describe("overrideMessage", () => { + it("default", () => { + ASRouterUtils.overrideMessage(12); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { id: 12 } }) + ); + }); + }); + describe("editState", () => { + it("default", () => { + ASRouterUtils.editState("foo", "bar"); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar" } }) + ); + }); + }); + describe("sendTelemetry", () => { + it("default", () => { + ASRouterUtils.sendTelemetry({ foo: "bar" }); + assert.calledOnce(globals.ASRouterMessage); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/constants.js b/browser/components/asrouter/tests/unit/constants.js new file mode 100644 index 0000000000..82b88c47a2 --- /dev/null +++ b/browser/components/asrouter/tests/unit/constants.js @@ -0,0 +1,131 @@ +export const CHILD_TO_PARENT_MESSAGE_NAME = "ASRouter:child-to-parent"; +export const PARENT_TO_CHILD_MESSAGE_NAME = "ASRouter:parent-to-child"; + +export const FAKE_LOCAL_MESSAGES = [ + { + id: "foo", + template: "milestone_message", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "foo1", + template: "fancy_template", + bundled: 2, + order: 1, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "fancy_template", + bundled: 2, + order: 2, + content: { title: "Foo2", body: "Foo123-2" }, + }, + { + id: "bar", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { id: "baz", content: { title: "Foo", body: "Foo123" } }, + { + id: "newsletter", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "fxa", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "belowsearch", + template: "fancy_template", + content: { text: "Foo" }, + }, +]; +export const FAKE_LOCAL_PROVIDER = { + id: "onboarding", + type: "local", + localProvider: "FAKE_LOCAL_PROVIDER", + enabled: true, + cohort: 0, +}; +export const FAKE_LOCAL_PROVIDERS = { + FAKE_LOCAL_PROVIDER: { + getMessages: () => Promise.resolve(FAKE_LOCAL_MESSAGES), + }, +}; + +export const FAKE_REMOTE_MESSAGES = [ + { + id: "qux", + template: "fancy_template", + content: { title: "Qux", body: "hello world" }, + }, +]; +export const FAKE_REMOTE_PROVIDER = { + id: "remotey", + type: "remote", + url: "http://fake.com/endpoint", + enabled: true, +}; + +export const FAKE_REMOTE_SETTINGS_PROVIDER = { + id: "remotey-settingsy", + type: "remote-settings", + collection: "collectionname", + enabled: true, +}; + +const notificationText = new String("Fake notification text"); // eslint-disable-line +notificationText.attributes = { tooltiptext: "Fake tooltip text" }; + +export const FAKE_RECOMMENDATION = { + id: "fake_id", + template: "cfr_doorhanger", + content: { + category: "cfrDummy", + bucket_id: "fake_bucket_id", + notification_text: notificationText, + info_icon: { + label: "Fake Info Icon Label", + sumo_path: "a_help_path_fragment", + }, + heading_text: "Fake Heading Text", + icon_class: "Fake Icon class", + addon: { + title: "Fake Addon Title", + author: "Fake Addon Author", + icon: "a_path_to_some_icon", + rating: "4.2", + users: "1234", + amo_url: "a_path_to_amo", + }, + descriptionDetails: { + steps: [{ string_id: "cfr-features-step1" }], + }, + text: "Here is the recommendation text body", + buttons: { + primary: { + label: { string_id: "primary_button_id" }, + action: { + id: "primary_action", + data: {}, + }, + }, + secondary: [ + { + label: { string_id: "secondary_button_id" }, + action: { id: "secondary_action" }, + }, + { + label: { string_id: "secondary_button_id_2" }, + }, + { + label: { string_id: "secondary_button_id_3" }, + action: { id: "secondary_action" }, + }, + ], + }, + }, +}; diff --git a/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx new file mode 100644 index 0000000000..46d5704107 --- /dev/null +++ b/browser/components/asrouter/tests/unit/content-src/components/ASRouterAdmin.test.jsx @@ -0,0 +1,262 @@ +import { ASRouterAdminInner } from "content-src/components/ASRouterAdmin/ASRouterAdmin"; +import { ASRouterUtils } from "content-src/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("ASRouterAdmin", () => { + let globalOverrider; + let sandbox; + let wrapper; + let globals; + let FAKE_PROVIDER_PREF = [ + { + enabled: true, + id: "local_testing", + localProvider: "TestProvider", + type: "local", + }, + ]; + let FAKE_PROVIDER = [ + { + enabled: true, + id: "local_testing", + localProvider: "TestProvider", + messages: [], + type: "local", + }, + ]; + beforeEach(() => { + globalOverrider = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + sandbox.stub(ASRouterUtils, "getPreviewEndpoint").returns("foo"); + globals = { + ASRouterMessage: sandbox.stub().resolves(), + ASRouterAddParentListener: sandbox.stub(), + ASRouterRemoveParentListener: sandbox.stub(), + }; + globalOverrider.set(globals); + wrapper = shallow(<ASRouterAdminInner location={{ routes: [""] }} />); + wrapper.setState({ devtoolsEnabled: true }); + }); + afterEach(() => { + sandbox.restore(); + globalOverrider.restore(); + }); + it("should render ASRouterAdmin component", () => { + assert.ok(wrapper.exists()); + }); + it("should send ADMIN_CONNECT_STATE on mount", () => { + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { + type: "ADMIN_CONNECT_STATE", + data: { endpoint: "foo" }, + }); + }); + describe("#getSection", () => { + it("should render a message provider section by default", () => { + assert.equal(wrapper.find("h2").at(1).text(), "Messages"); + }); + it("should render a targeting section for targeting route", () => { + wrapper = shallow( + <ASRouterAdminInner location={{ routes: ["targeting"] }} /> + ); + wrapper.setState({ devtoolsEnabled: true }); + assert.equal(wrapper.find("h2").at(0).text(), "Targeting Utilities"); + }); + it("should render two error messages", () => { + wrapper = shallow( + <ASRouterAdminInner location={{ routes: ["errors"] }} Sections={[]} /> + ); + wrapper.setState({ devtoolsEnabled: true }); + const firstError = { + timestamp: Date.now() + 100, + error: { message: "first" }, + }; + const secondError = { + timestamp: Date.now(), + error: { message: "second" }, + }; + wrapper.setState({ + providers: [{ id: "foo", errors: [firstError, secondError] }], + }); + + assert.equal( + wrapper.find("tbody tr").at(0).find("td").at(0).text(), + "foo" + ); + assert.lengthOf(wrapper.find("tbody tr"), 2); + assert.equal( + wrapper.find("tbody tr").at(0).find("td").at(1).text(), + secondError.error.message + ); + }); + }); + describe("#render", () => { + beforeEach(() => { + wrapper.setState({ + providerPrefs: [], + providers: [], + userPrefs: {}, + }); + }); + describe("#renderProviders", () => { + it("should render the provider", () => { + wrapper.setState({ + providerPrefs: FAKE_PROVIDER_PREF, + providers: FAKE_PROVIDER, + }); + + // Header + 1 item + assert.lengthOf(wrapper.find(".message-item"), 2); + }); + }); + describe("#renderMessages", () => { + beforeEach(() => { + sandbox.stub(ASRouterUtils, "blockById").resolves(); + sandbox.stub(ASRouterUtils, "unblockById").resolves(); + sandbox.stub(ASRouterUtils, "overrideMessage").resolves({ foo: "bar" }); + sandbox.stub(ASRouterUtils, "sendMessage").resolves(); + wrapper.setState({ + messageFilter: "all", + messageBlockList: [], + messageImpressions: { foo: 2 }, + groups: [{ id: "messageProvider", enabled: true }], + providers: [{ id: "messageProvider", enabled: true }], + }); + }); + it("should render a message when no filtering is applied", () => { + wrapper.setState({ + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + wrapper.find(".message-item button.primary").simulate("click"); + assert.calledOnce(ASRouterUtils.blockById); + assert.calledWith(ASRouterUtils.blockById, "foo"); + }); + it("should render a blocked message", () => { + wrapper.setState({ + messages: [ + { + id: "foo", + groups: ["messageProvider"], + provider: "messageProvider", + }, + ], + messageBlockList: ["foo"], + }); + assert.lengthOf(wrapper.find(".message-item.blocked"), 1); + wrapper.find(".message-item.blocked button").simulate("click"); + assert.calledOnce(ASRouterUtils.unblockById); + assert.calledWith(ASRouterUtils.unblockById, "foo"); + }); + it("should render a message if provider matches filter", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + }); + it("should override with the selected message", async () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + wrapper.find(".message-item button.show").simulate("click"); + assert.calledOnce(ASRouterUtils.overrideMessage); + assert.calledWith(ASRouterUtils.overrideMessage, "foo"); + await ASRouterUtils.overrideMessage(); + assert.equal(wrapper.state().foo, "bar"); + }); + it("should hide message if provider filter changes", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + + wrapper.find("select").simulate("change", { target: { value: "bar" } }); + + assert.lengthOf(wrapper.find(".message-id"), 0); + }); + it("should not display Reset All button if provider filter value is set to all or test providers", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".messages-reset"), 1); + wrapper.find("select").simulate("change", { target: { value: "all" } }); + + assert.lengthOf(wrapper.find(".messages-reset"), 0); + + wrapper + .find("select") + .simulate("change", { target: { value: "test_local_testing" } }); + assert.lengthOf(wrapper.find(".messages-reset"), 0); + }); + it("should trigger disable and enable provider on Reset All button click", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + providerPrefs: [ + { + id: "messageProvider", + }, + ], + }); + wrapper.find(".messages-reset").simulate("click"); + assert.calledTwice(ASRouterUtils.sendMessage); + assert.calledWith(ASRouterUtils.sendMessage, { + type: "DISABLE_PROVIDER", + data: "messageProvider", + }); + assert.calledWith(ASRouterUtils.sendMessage, { + type: "ENABLE_PROVIDER", + data: "messageProvider", + }); + }); + }); + }); +}); diff --git a/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx b/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx new file mode 100644 index 0000000000..9d9984ee85 --- /dev/null +++ b/browser/components/asrouter/tests/unit/templates/ExtensionDoorhanger.test.jsx @@ -0,0 +1,112 @@ +import { CFRMessageProvider } from "modules/CFRMessageProvider.sys.mjs"; +import CFRDoorhangerSchema from "content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json"; +import CFRChicletSchema from "content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json"; +import InfoBarSchema from "content-src/templates/CFR/templates/InfoBar.schema.json"; + +const SCHEMAS = { + cfr_urlbar_chiclet: CFRChicletSchema, + cfr_doorhanger: CFRDoorhangerSchema, + milestone_message: CFRDoorhangerSchema, + infobar: InfoBarSchema, +}; + +const DEFAULT_CONTENT = { + layout: "addon_recommendation", + category: "dummyCategory", + bucket_id: "some_bucket_id", + notification_text: "Recommendation", + heading_text: "Recommended Extension", + info_icon: { + label: { attributes: { tooltiptext: "Why am I seeing this" } }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "1234", + title: "Addon name", + icon: "https://mozilla.org/icon", + author: "Author name", + amo_url: "https://example.com", + }, + text: "Description of addon", + buttons: { + primary: { + label: { + value: "Add Now", + attributes: { accesskey: "A" }, + }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com" }, + }, + }, + secondary: [ + { + label: { + value: "Not Now", + attributes: { accesskey: "N" }, + }, + action: { type: "CANCEL" }, + }, + ], + }, +}; + +const L10N_CONTENT = { + layout: "addon_recommendation", + category: "dummyL10NCategory", + bucket_id: "some_bucket_id", + notification_text: { string_id: "notification_text_id" }, + heading_text: { string_id: "heading_text_id" }, + info_icon: { + label: { string_id: "why_seeing_this" }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "1234", + title: "Addon name", + icon: "https://mozilla.org/icon", + author: "Author name", + amo_url: "https://example.com", + }, + text: { string_id: "text_id" }, + buttons: { + primary: { + label: { string_id: "btn_ok_id" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com" }, + }, + }, + secondary: [ + { + label: { string_id: "btn_cancel_id" }, + action: { type: "CANCEL" }, + }, + ], + }, +}; + +describe("ExtensionDoorhanger", () => { + it("should validate DEFAULT_CONTENT", async () => { + const messages = await CFRMessageProvider.getMessages(); + let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3"); + assert.ok(doorhangerMessage, "Message found"); + assert.jsonSchema( + { ...doorhangerMessage, content: DEFAULT_CONTENT }, + CFRDoorhangerSchema + ); + }); + it("should validate L10N_CONTENT", async () => { + const messages = await CFRMessageProvider.getMessages(); + let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3"); + assert.ok(doorhangerMessage, "Message found"); + assert.jsonSchema( + { ...doorhangerMessage, content: L10N_CONTENT }, + CFRDoorhangerSchema + ); + }); + it("should validate all messages from CFRMessageProvider", async () => { + const messages = await CFRMessageProvider.getMessages(); + messages.forEach(msg => assert.jsonSchema(msg, SCHEMAS[msg.template])); + }); +}); diff --git a/browser/components/asrouter/tests/unit/unit-entry.js b/browser/components/asrouter/tests/unit/unit-entry.js new file mode 100644 index 0000000000..b8b799e051 --- /dev/null +++ b/browser/components/asrouter/tests/unit/unit-entry.js @@ -0,0 +1,727 @@ +import { + EventEmitter, + FakePrefs, + FakensIPrefService, + GlobalOverrider, + FakeConsoleAPI, + FakeLogger, +} from "newtab/test/unit/utils"; +import Adapter from "enzyme-adapter-react-16"; +import { chaiAssertions } from "newtab/test/schemas/pings"; +import chaiJsonSchema from "chai-json-schema"; +import enzyme from "enzyme"; +import FxMSCommonSchema from "../../content-src/schemas/FxMSCommon.schema.json"; +import { + MESSAGE_TYPE_LIST, + MESSAGE_TYPE_HASH, +} from "modules/ActorConstants.sys.mjs"; + +enzyme.configure({ adapter: new Adapter() }); + +// Cause React warnings to make tests that trigger them fail +const origConsoleError = console.error; +console.error = function (msg, ...args) { + origConsoleError.apply(console, [msg, ...args]); + + if ( + /(Invalid prop|Failed prop type|Check the render method|React Intl)/.test( + msg + ) + ) { + throw new Error(msg); + } +}; + +const req = require.context(".", true, /\.test\.jsx?$/); +const files = req.keys(); + +// This exposes sinon assertions to chai.assert +sinon.assert.expose(assert, { prefix: "" }); + +chai.use(chaiAssertions); +chai.use(chaiJsonSchema); +chai.tv4.addSchema("file:///FxMSCommon.schema.json", FxMSCommonSchema); + +const overrider = new GlobalOverrider(); + +const RemoteSettings = name => ({ + get: () => { + if (name === "attachment") { + return Promise.resolve([{ attachment: {} }]); + } + return Promise.resolve([]); + }, + on: () => {}, + off: () => {}, +}); +RemoteSettings.pollChanges = () => {}; + +class JSWindowActorParent { + sendAsyncMessage(name, data) { + return { name, data }; + } +} + +class JSWindowActorChild { + sendAsyncMessage(name, data) { + return { name, data }; + } + + sendQuery(name, data) { + return Promise.resolve({ name, data }); + } + + get contentWindow() { + return { + Promise, + }; + } +} + +// Detect plain object passed to lazy getter APIs, and set its prototype to +// global object, and return the global object for further modification. +// Returns the object if it's not plain object. +// +// This is a workaround to make the existing testharness and testcase keep +// working even after lazy getters are moved to plain `lazy` object. +const cachedPlainObject = new Set(); +function updateGlobalOrObject(object) { + // Given this function modifies the prototype, and the following + // condition doesn't meet on the second call, cache the result. + if (cachedPlainObject.has(object)) { + return global; + } + + if (Object.getPrototypeOf(object).constructor.name !== "Object") { + return object; + } + + cachedPlainObject.add(object); + Object.setPrototypeOf(object, global); + return global; +} + +const TEST_GLOBAL = { + JSWindowActorParent, + JSWindowActorChild, + AboutReaderParent: { + addMessageListener: (messageName, listener) => {}, + removeMessageListener: (messageName, listener) => {}, + }, + AboutWelcomeTelemetry: class { + submitGleanPingForPing() {} + }, + AddonManager: { + getActiveAddons() { + return Promise.resolve({ addons: [], fullData: false }); + }, + }, + AppConstants: { + MOZILLA_OFFICIAL: true, + MOZ_APP_VERSION: "69.0a1", + isChinaRepack() { + return false; + }, + isPlatformAndVersionAtMost() { + return false; + }, + platform: "win", + }, + ASRouterPreferences: { + console: new FakeConsoleAPI({ + maxLogLevel: "off", // set this to "debug" or "all" to get more ASRouter logging in tests + prefix: "ASRouter", + }), + }, + AWScreenUtils: { + evaluateTargetingAndRemoveScreens() { + return true; + }, + async removeScreens() { + return true; + }, + evaluateScreenTargeting() { + return true; + }, + }, + BrowserUtils: { + sendToDeviceEmailsSupported() { + return true; + }, + }, + UpdateUtils: { getUpdateChannel() {} }, + BasePromiseWorker: class { + constructor() { + this.ExceptionHandlers = []; + } + post() {} + }, + browserSearchRegion: "US", + BrowserWindowTracker: { getTopWindow() {} }, + ChromeUtils: { + defineLazyGetter(object, name, f) { + updateGlobalOrObject(object)[name] = f(); + }, + defineModuleGetter: updateGlobalOrObject, + defineESModuleGetters: updateGlobalOrObject, + generateQI() { + return {}; + }, + import() { + return global; + }, + importESModule() { + return global; + }, + }, + ClientEnvironment: { + get userId() { + return "foo123"; + }, + }, + Components: { + Constructor(classId) { + switch (classId) { + case "@mozilla.org/referrer-info;1": + return function (referrerPolicy, sendReferrer, originalReferrer) { + this.referrerPolicy = referrerPolicy; + this.sendReferrer = sendReferrer; + this.originalReferrer = originalReferrer; + }; + } + return function () {}; + }, + isSuccessCode: () => true, + }, + ConsoleAPI: FakeConsoleAPI, + // NB: These are functions/constructors + // eslint-disable-next-line object-shorthand + ContentSearchUIController: function () {}, + // eslint-disable-next-line object-shorthand + ContentSearchHandoffUIController: function () {}, + Cc: { + "@mozilla.org/browser/nav-bookmarks-service;1": { + addObserver() {}, + getService() { + return this; + }, + removeObserver() {}, + SOURCES: {}, + TYPE_BOOKMARK: {}, + }, + "@mozilla.org/browser/nav-history-service;1": { + addObserver() {}, + executeQuery() {}, + getNewQuery() {}, + getNewQueryOptions() {}, + getService() { + return this; + }, + insert() {}, + markPageAsTyped() {}, + removeObserver() {}, + }, + "@mozilla.org/io/string-input-stream;1": { + createInstance() { + return {}; + }, + }, + "@mozilla.org/security/hash;1": { + createInstance() { + return { + init() {}, + updateFromStream() {}, + finish() { + return "0"; + }, + }; + }, + }, + "@mozilla.org/updates/update-checker;1": { createInstance() {} }, + "@mozilla.org/widget/useridleservice;1": { + getService() { + return { + idleTime: 0, + addIdleObserver() {}, + removeIdleObserver() {}, + }; + }, + }, + "@mozilla.org/streamConverters;1": { + getService() { + return this; + }, + }, + "@mozilla.org/network/stream-loader;1": { + createInstance() { + return {}; + }, + }, + }, + Ci: { + nsICryptoHash: {}, + nsIReferrerInfo: { UNSAFE_URL: 5 }, + nsITimer: { TYPE_ONE_SHOT: 1 }, + nsIWebProgressListener: { LOCATION_CHANGE_SAME_DOCUMENT: 1 }, + nsIDOMWindow: Object, + nsITrackingDBService: { + TRACKERS_ID: 1, + TRACKING_COOKIES_ID: 2, + CRYPTOMINERS_ID: 3, + FINGERPRINTERS_ID: 4, + SOCIAL_ID: 5, + }, + nsICookieBannerService: { + MODE_DISABLED: 0, + MODE_REJECT: 1, + MODE_REJECT_OR_ACCEPT: 2, + MODE_UNSET: 3, + }, + }, + Cu: { + importGlobalProperties() {}, + now: () => window.performance.now(), + cloneInto: o => JSON.parse(JSON.stringify(o)), + }, + console: { + ...console, + error() {}, + }, + dump() {}, + EveryWindow: { + registerCallback: (id, init, uninit) => {}, + unregisterCallback: id => {}, + }, + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + fetch() {}, + // eslint-disable-next-line object-shorthand + Image: function () {}, // NB: This is a function/constructor + IOUtils: { + writeJSON() { + return Promise.resolve(0); + }, + readJSON() { + return Promise.resolve({}); + }, + read() { + return Promise.resolve(new Uint8Array()); + }, + makeDirectory() { + return Promise.resolve(0); + }, + write() { + return Promise.resolve(0); + }, + exists() { + return Promise.resolve(0); + }, + remove() { + return Promise.resolve(0); + }, + stat() { + return Promise.resolve(0); + }, + }, + NewTabUtils: { + activityStreamProvider: { + getTopFrecentSites: () => [], + executePlacesQuery: async (sql, options) => ({ sql, options }), + }, + }, + OS: { + File: { + writeAtomic() {}, + makeDir() {}, + stat() {}, + Error: {}, + read() {}, + exists() {}, + remove() {}, + removeEmptyDir() {}, + }, + Path: { + join() { + return "/"; + }, + }, + Constants: { + Path: { + localProfileDir: "/", + }, + }, + }, + PathUtils: { + join(...parts) { + return parts[parts.length - 1]; + }, + joinRelative(...parts) { + return parts[parts.length - 1]; + }, + getProfileDir() { + return Promise.resolve("/"); + }, + getLocalProfileDir() { + return Promise.resolve("/"); + }, + }, + PlacesUtils: { + get bookmarks() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-bookmarks-service;1"]; + }, + get history() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-history-service;1"]; + }, + observers: { + addListener() {}, + removeListener() {}, + }, + }, + Preferences: FakePrefs, + PrivateBrowsingUtils: { + isBrowserPrivate: () => false, + isWindowPrivate: () => false, + permanentPrivateBrowsing: false, + }, + DownloadsViewUI: { + getDisplayName: () => "filename.ext", + getSizeWithUnits: () => "1.5 MB", + }, + FileUtils: { + // eslint-disable-next-line object-shorthand + File: function () {}, // NB: This is a function/constructor + }, + Region: { + home: "US", + REGION_TOPIC: "browser-region-updated", + }, + Services: { + dirsvc: { + get: () => ({ parent: { parent: { path: "appPath" } } }), + }, + env: { + set: () => undefined, + }, + locale: { + get appLocaleAsBCP47() { + return "en-US"; + }, + negotiateLanguages() {}, + }, + urlFormatter: { formatURL: str => str, formatURLPref: str => str }, + mm: { + addMessageListener: (msg, cb) => this.receiveMessage(), + removeMessageListener() {}, + }, + obs: { + addObserver() {}, + removeObserver() {}, + notifyObservers() {}, + }, + telemetry: { + setEventRecordingEnabled: () => {}, + recordEvent: eventDetails => {}, + scalarSet: () => {}, + keyedScalarAdd: () => {}, + }, + uuid: { + generateUUID() { + return "{foo-123-foo}"; + }, + }, + console: { logStringMessage: () => {} }, + prefs: new FakensIPrefService(), + tm: { + dispatchToMainThread: cb => cb(), + idleDispatchToMainThread: cb => cb(), + }, + eTLD: { + getBaseDomain({ spec }) { + return spec.match(/\/([^/]+)/)[1]; + }, + getBaseDomainFromHost(host) { + return host.match(/.*?(\w+\.\w+)$/)[1]; + }, + getPublicSuffix() {}, + }, + io: { + newURI: spec => ({ + mutate: () => ({ + setRef: ref => ({ + finalize: () => ({ + ref, + spec, + }), + }), + }), + spec, + }), + }, + search: { + init() { + return Promise.resolve(); + }, + getVisibleEngines: () => + Promise.resolve([{ identifier: "google" }, { identifier: "bing" }]), + defaultEngine: { + identifier: "google", + searchForm: + "https://www.google.com/search?q=&ie=utf-8&oe=utf-8&client=firefox-b", + aliases: ["@google"], + }, + defaultPrivateEngine: { + identifier: "bing", + searchForm: "https://www.bing.com", + aliases: ["@bing"], + }, + getEngineByAlias: async () => null, + }, + scriptSecurityManager: { + createNullPrincipal() {}, + getSystemPrincipal() {}, + }, + wm: { + getMostRecentWindow: () => window, + getMostRecentBrowserWindow: () => window, + getEnumerator: () => [], + }, + ww: { registerNotification() {}, unregisterNotification() {} }, + appinfo: { appBuildID: "20180710100040", version: "69.0a1" }, + scriptloader: { loadSubScript: () => {} }, + startup: { + getStartupInfo() { + return { + process: { + getTime() { + return 1588010448000; + }, + }, + }; + }, + }, + }, + XPCOMUtils: { + defineLazyGlobalGetters: updateGlobalOrObject, + defineLazyModuleGetters: updateGlobalOrObject, + defineLazyServiceGetter: updateGlobalOrObject, + defineLazyServiceGetters: updateGlobalOrObject, + defineLazyPreferenceGetter(object, name) { + updateGlobalOrObject(object)[name] = ""; + }, + generateQI() { + return {}; + }, + }, + EventEmitter, + ShellService: { + doesAppNeedPin: () => false, + isDefaultBrowser: () => true, + }, + FilterExpressions: { + eval() { + return Promise.resolve(false); + }, + }, + RemoteSettings, + Localization: class { + async formatMessages(stringsIds) { + return Promise.resolve( + stringsIds.map(({ id, args }) => ({ value: { string_id: id, args } })) + ); + } + async formatValue(stringId) { + return Promise.resolve(stringId); + } + }, + FxAccountsConfig: { + promiseConnectAccountURI(id) { + return Promise.resolve(id); + }, + }, + FX_MONITOR_OAUTH_CLIENT_ID: "fake_client_id", + ExperimentAPI: { + getExperiment() {}, + getExperimentMetaData() {}, + getRolloutMetaData() {}, + }, + NimbusFeatures: { + glean: { + getVariable() {}, + }, + newtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + pocketNewtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + cookieBannerHandling: { + getVariable() {}, + }, + }, + TelemetryEnvironment: { + setExperimentActive() {}, + currentEnvironment: { + profile: { + creationDate: 16587, + }, + settings: {}, + }, + }, + TelemetryStopwatch: { + start: () => {}, + finish: () => {}, + }, + Sampling: { + ratioSample(seed, ratios) { + return Promise.resolve(0); + }, + }, + BrowserHandler: { + get kiosk() { + return false; + }, + }, + TelemetrySession: { + getMetadata(reason) { + return { + reason, + sessionId: "fake_session_id", + }; + }, + }, + PageThumbs: { + addExpirationFilter() {}, + removeExpirationFilter() {}, + }, + Logger: FakeLogger, + getFxAccountsSingleton() {}, + AboutNewTab: {}, + Glean: { + newtab: { + opened: { + record() {}, + }, + closed: { + record() {}, + }, + locale: { + set() {}, + }, + newtabCategory: { + set() {}, + }, + homepageCategory: { + set() {}, + }, + blockedSponsors: { + set() {}, + }, + sovAllocation: { + set() {}, + }, + }, + newtabSearch: { + enabled: { + set() {}, + }, + }, + pocket: { + enabled: { + set() {}, + }, + impression: { + record() {}, + }, + isSignedIn: { + set() {}, + }, + sponsoredStoriesEnabled: { + set() {}, + }, + click: { + record() {}, + }, + save: { + record() {}, + }, + topicClick: { + record() {}, + }, + }, + topsites: { + enabled: { + set() {}, + }, + sponsoredEnabled: { + set() {}, + }, + impression: { + record() {}, + }, + click: { + record() {}, + }, + rows: { + set() {}, + }, + showPrivacyClick: { + record() {}, + }, + dismiss: { + record() {}, + }, + prefChanged: { + record() {}, + }, + }, + topSites: { + pingType: { + set() {}, + }, + position: { + set() {}, + }, + source: { + set() {}, + }, + tileId: { + set() {}, + }, + reportingUrl: { + set() {}, + }, + advertiser: { + set() {}, + }, + contextId: { + set() {}, + }, + }, + }, + GleanPings: { + newtab: { + submit() {}, + }, + topSites: { + submit() {}, + }, + }, + Utils: { + SERVER_URL: "bogus://foo", + }, + + MESSAGE_TYPE_LIST, + MESSAGE_TYPE_HASH, +}; +overrider.set(TEST_GLOBAL); + +describe("asrouter", () => { + after(() => overrider.restore()); + files.forEach(file => req(file)); +}); diff --git a/browser/components/asrouter/tests/xpcshell/head.js b/browser/components/asrouter/tests/xpcshell/head.js new file mode 100644 index 0000000000..0c6cec1ac8 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/head.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs", +}); + +function assertValidates(validator, obj, msg) { + const result = validator.validate(obj); + Assert.ok( + result.valid && result.errors.length === 0, + `${msg} - errors = ${JSON.stringify(result.errors, undefined, 2)}` + ); +} + +async function fetchSchema(uri) { + try { + dump(`URI: ${uri}\n`); + return fetch(uri, { credentials: "omit" }).then(rsp => rsp.json()); + } catch (e) { + throw new Error(`Could not fetch ${uri}`); + } +} + +async function schemaValidatorFor(uri, { common = false } = {}) { + const schema = await fetchSchema(uri); + const validator = new lazy.JsonSchema.Validator(schema); + + if (common) { + const commonSchema = await fetchSchema( + "resource://testing-common/FxMSCommon.schema.json" + ); + validator.addSchema(commonSchema); + } + + return validator; +} + +async function makeValidators() { + const experimentValidator = await schemaValidatorFor( + "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json" + ); + + const messageValidators = { + cfr_doorhanger: await schemaValidatorFor( + "resource://testing-common/ExtensionDoorhanger.schema.json", + { common: true } + ), + cfr_urlbar_chiclet: await schemaValidatorFor( + "resource://testing-common/CFRUrlbarChiclet.schema.json", + { common: true } + ), + infobar: await schemaValidatorFor( + "resource://testing-common/InfoBar.schema.json", + { common: true } + ), + pb_newtab: await schemaValidatorFor( + "resource://testing-common/NewtabPromoMessage.schema.json", + { common: true } + ), + spotlight: await schemaValidatorFor( + "resource://testing-common/Spotlight.schema.json", + { common: true } + ), + toast_notification: await schemaValidatorFor( + "resource://testing-common/ToastNotification.schema.json", + { common: true } + ), + toolbar_badge: await schemaValidatorFor( + "resource://testing-common/ToolbarBadgeMessage.schema.json", + { common: true } + ), + update_action: await schemaValidatorFor( + "resource://testing-common/UpdateAction.schema.json", + { common: true } + ), + whatsnew_panel_message: await schemaValidatorFor( + "resource://testing-common/WhatsNewMessage.schema.json", + { common: true } + ), + feature_callout: await schemaValidatorFor( + // For now, Feature Callout and Spotlight share a common schema + "resource://testing-common/Spotlight.schema.json", + { common: true } + ), + }; + + messageValidators.milestone_message = messageValidators.cfr_doorhanger; + + return { experimentValidator, messageValidators }; +} diff --git a/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js new file mode 100644 index 0000000000..a37cb6c793 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_attribution.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { AttributionCode } = ChromeUtils.importESModule( + "resource:///modules/AttributionCode.sys.mjs" +); +const { ASRouterTargeting } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterTargeting.sys.mjs" +); +const { MacAttribution } = ChromeUtils.importESModule( + "resource:///modules/MacAttribution.sys.mjs" +); +const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" +); + +add_task(async function check_attribution_data() { + // Some setup to fake the correct attribution data + const campaign = "non-fx-button"; + const source = "addons.mozilla.org"; + const attrStr = `campaign%3D${campaign}%26source%3D${source}`; + await MacAttribution.setAttributionString(attrStr); + AttributionCode._clearCache(); + await AttributionCode.getAttrDataAsync(); + + const { campaign: attributionCampain, source: attributionSource } = + ASRouterTargeting.Environment.attributionData; + equal( + attributionCampain, + campaign, + "should get the correct campaign out of attributionData" + ); + equal( + attributionSource, + source, + "should get the correct source out of attributionData" + ); + + const messages = [ + { + id: "foo1", + targeting: + "attributionData.campaign == 'back_to_school' && attributionData.source == 'addons.mozilla.org'", + }, + { + id: "foo2", + targeting: + "attributionData.campaign == 'non-fx-button' && attributionData.source == 'addons.mozilla.org'", + }, + ]; + + equal( + await ASRouterTargeting.findMatchingMessage({ messages }), + messages[1], + "should select the message with the correct campaign and source" + ); + AttributionCode._clearCache(); +}); + +add_task(async function check_enterprise_targeting() { + const messages = [ + { + id: "foo1", + targeting: "hasActiveEnterprisePolicies", + }, + { + id: "foo2", + targeting: "!hasActiveEnterprisePolicies", + }, + ]; + + equal( + await ASRouterTargeting.findMatchingMessage({ messages }), + messages[1], + "should select the message for policies turned off" + ); + + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + DisableFirefoxStudies: { + Value: true, + }, + }, + }); + + equal( + await ASRouterTargeting.findMatchingMessage({ messages }), + messages[0], + "should select the message for policies turned on" + ); +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js new file mode 100644 index 0000000000..74171ba1b9 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_ASRouterTargeting_snapshot.js @@ -0,0 +1,172 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { ASRouterTargeting } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterTargeting.sys.mjs" +); + +add_task(async function should_ignore_rejections() { + let target = { + get foo() { + return new Promise(resolve => resolve(1)); + }, + + get bar() { + return new Promise((resolve, reject) => reject(new Error("unspecified"))); + }, + }; + + let snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target], + }); + Assert.deepEqual(snapshot, { environment: { foo: 1 }, version: 1 }); +}); + +add_task(async function nested_objects() { + const target = { + get foo() { + return Promise.resolve("foo"); + }, + get bar() { + return Promise.reject(new Error("bar")); + }, + baz: { + get qux() { + return Promise.resolve("qux"); + }, + get quux() { + return Promise.reject(new Error("quux")); + }, + get corge() { + return { + get grault() { + return Promise.resolve("grault"); + }, + get garply() { + return Promise.reject(new Error("garply")); + }, + }; + }, + }, + }; + + const snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target], + }); + Assert.deepEqual( + snapshot, + { + environment: { + foo: "foo", + baz: { + qux: "qux", + corge: { + grault: "grault", + }, + }, + }, + version: 1, + }, + "getEnvironmentSnapshot should resolve nested promises" + ); +}); + +add_task(async function arrays() { + const target = { + foo: [1, 2, 3], + bar: [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)], + baz: Promise.resolve([1, 2, 3]), + qux: Promise.resolve([ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3), + ]), + quux: Promise.resolve({ + corge: [Promise.resolve(1), 2, 3], + }), + }; + + const snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target], + }); + Assert.deepEqual( + snapshot, + { + environment: { + foo: [1, 2, 3], + bar: [1, 2, 3], + baz: [1, 2, 3], + qux: [1, 2, 3], + quux: { corge: [1, 2, 3] }, + }, + version: 1, + }, + "getEnvironmentSnapshot should resolve arrays correctly" + ); +}); + +add_task(async function target_order() { + let target1 = { + foo: 1, + bar: 1, + baz: 1, + }; + + let target2 = { + foo: 2, + bar: 2, + }; + + let target3 = { + foo: 3, + }; + + // target3 supercedes target2; both supercede target1. + let snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target3, target2, target1], + }); + Assert.deepEqual(snapshot, { + environment: { foo: 3, bar: 2, baz: 1 }, + version: 1, + }); +}); + +/* + * NB: This test is last because it manipulates shutdown phases. + * + * Adding tests after this one will result in failures. + */ +add_task(async function should_ignore_rejections() { + // The order that `ASRouterTargeting.getEnvironmentSnapshot` + // enumerates the target object matters here, but it's guaranteed to + // be consistent by the `for ... in` ordering: see + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in#description. + let target = { + get foo() { + return new Promise(resolve => resolve(1)); + }, + + get bar() { + return new Promise(resolve => { + // Pretend that we're about to shut down. + Services.startup.advanceShutdownPhase( + Services.startup.SHUTDOWN_PHASE_APPSHUTDOWN + ); + resolve(2); + }); + }, + + get baz() { + return new Promise(resolve => resolve(3)); + }, + }; + + let snapshot = await ASRouterTargeting.getEnvironmentSnapshot({ + targets: [target], + }); + // `baz` is dropped since we're shutting down by the time it's processed. + Assert.deepEqual(snapshot, { environment: { foo: 1, bar: 2 }, version: 1 }); +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js b/browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js new file mode 100644 index 0000000000..bda6d0cd41 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_ASRouter_getTargetingParameters.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +add_task(async function nested_objects() { + const target = { + get foo() { + return Promise.resolve("foo"); + }, + baz: { + get qux() { + return Promise.resolve("qux"); + }, + get corge() { + return { + get grault() { + return Promise.resolve("grault"); + }, + }; + }, + }, + }; + + const params = await ASRouter.getTargetingParameters(target); + Assert.deepEqual( + params, + { + foo: "foo", + baz: { + qux: "qux", + corge: { + grault: "grault", + }, + }, + }, + "getTargetingParameters should resolve nested promises" + ); +}); + +add_task(async function arrays() { + const target = { + foo: [1, 2, 3], + bar: [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)], + baz: Promise.resolve([1, 2, 3]), + qux: Promise.resolve([ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3), + ]), + quux: Promise.resolve({ + corge: [Promise.resolve(1), 2, 3], + }), + }; + + const params = await ASRouter.getTargetingParameters(target); + Assert.deepEqual( + params, + { + foo: [1, 2, 3], + bar: [1, 2, 3], + baz: [1, 2, 3], + qux: [1, 2, 3], + quux: { corge: [1, 2, 3] }, + }, + "getEnvironmentSnapshot should resolve arrays correctly" + ); +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js new file mode 100644 index 0000000000..3354013067 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_CFRMessageProvider.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +add_task(async function test_cfrMessages() { + const { experimentValidator, messageValidators } = await makeValidators(); + + const messages = await CFRMessageProvider.getMessages(); + for (const message of messages) { + const validator = messageValidators[message.template]; + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}.` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + + assertValidates( + validator, + message, + `Message ${message.id} validates as template ${message.template}` + ); + assertValidates( + experimentValidator, + message, + `Message ${message.id} validates as MessagingExperiment` + ); + } +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js new file mode 100644 index 0000000000..fce99362c7 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_InflightAssetsMessageProvider.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { InflightAssetsMessageProvider } = ChromeUtils.importESModule( + "resource://testing-common/InflightAssetsMessageProvider.sys.mjs" +); + +const MESSAGE_VALIDATORS = {}; +let EXPERIMENT_VALIDATOR; + +add_setup(async function setup() { + const validators = await makeValidators(); + + EXPERIMENT_VALIDATOR = validators.experimentValidator; + Object.assign(MESSAGE_VALIDATORS, validators.messageValidators); +}); + +add_task(function test_InflightAssetsMessageProvider() { + const messages = InflightAssetsMessageProvider.getMessages(); + + for (const message of messages) { + const validator = MESSAGE_VALIDATORS[message.template]; + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + + assertValidates( + validator, + message, + `Message ${message.id} validates as ${message.template} template` + ); + assertValidates( + EXPERIMENT_VALIDATOR, + message, + `Message ${message.id} validates as a MessagingExperiment` + ); + } +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js new file mode 100644 index 0000000000..2fe01e2fed --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_NimbusRolloutMessageProvider.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { NimbusRolloutMessageProvider } = ChromeUtils.importESModule( + "resource://testing-common/NimbusRolloutMessageProvider.sys.mjs" +); + +const MESSAGE_VALIDATORS = {}; +let EXPERIMENT_VALIDATOR; + +add_setup(async function setup() { + const validators = await makeValidators(); + + EXPERIMENT_VALIDATOR = validators.experimentValidator; + Object.assign(MESSAGE_VALIDATORS, validators.messageValidators); +}); + +add_task(function test_NimbusRolloutMessageProvider() { + const messages = NimbusRolloutMessageProvider.getMessages(); + + for (const message of messages) { + const validator = MESSAGE_VALIDATORS[message.template]; + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + + assertValidates( + validator, + message, + `Message ${message.id} validates as ${message.template} template` + ); + assertValidates( + EXPERIMENT_VALIDATOR, + message, + `Message ${message.id} validates as a MessagingExperiment` + ); + } +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js b/browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js new file mode 100644 index 0000000000..7ea7c97a03 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_OnboardingMessageProvider.js @@ -0,0 +1,229 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { OnboardingMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +function getOnboardingScreenById(screens, screenId) { + return screens.find(screen => { + return screen?.id === screenId; + }); +} + +add_task( + async function test_OnboardingMessageProvider_getUpgradeMessage_no_pin() { + let sandbox = sinon.createSandbox(); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(true); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + // If Firefox is not pinned, the screen should have "pin" content + equal( + message.content.screens[0].id, + "UPGRADE_PIN_FIREFOX", + "Screen has pin screen id" + ); + equal( + message.content.screens[0].content.primary_button.action.type, + "PIN_FIREFOX_TO_TASKBAR", + "Primary button has pin action type" + ); + sandbox.restore(); + } +); + +add_task( + async function test_OnboardingMessageProvider_getUpgradeMessage_pin_no_default() { + let sandbox = sinon.createSandbox(); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false); + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(true); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + // If Firefox is pinned, but not the default, the screen should have "make default" content + equal( + message.content.screens[0].id, + "UPGRADE_ONLY_DEFAULT", + "Screen has make default screen id" + ); + equal( + message.content.screens[0].content.primary_button.action.type, + "SET_DEFAULT_BROWSER", + "Primary button has make default action" + ); + sandbox.restore(); + } +); + +add_task( + async function test_OnboardingMessageProvider_getUpgradeMessage_pin_and_default() { + let sandbox = sinon.createSandbox(); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false); + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(false); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + // If Firefox is pinned and the default, the screen should have "get started" content + equal( + message.content.screens[0].id, + "UPGRADE_GET_STARTED", + "Screen has get started screen id" + ); + ok( + !message.content.screens[0].content.primary_button.action.type, + "Primary button has no action type" + ); + sandbox.restore(); + } +); + +add_task(async function test_OnboardingMessageProvider_getNoImport_default() { + let sandbox = sinon.createSandbox(); + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(false); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + + // No import screen is shown when user has Firefox both pinned and default + Assert.notEqual( + message.content.screens[1]?.id, + "UPGRADE_IMPORT_SETTINGS_EMBEDDED", + "Screen has no import screen id" + ); + sandbox.restore(); +}); + +add_task(async function test_OnboardingMessageProvider_getImport_nodefault() { + Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser"); + }); + + let sandbox = sinon.createSandbox(); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedDefault").resolves(true); + sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin").resolves(false); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + + // Import screen is shown when user doesn't have Firefox pinned and default + Assert.equal( + message.content.screens[1]?.id, + "UPGRADE_IMPORT_SETTINGS_EMBEDDED", + "Screen has import screen id" + ); + sandbox.restore(); +}); + +add_task( + async function test_OnboardingMessageProvider_getPinPrivateWindow_noPrivatePin() { + Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser"); + }); + let sandbox = sinon.createSandbox(); + // User needs default to ensure Pin Private window shows as third screen after import + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(true); + + let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin"); + pinStub.resolves(false); + pinStub.withArgs(true).resolves(true); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + + // Pin Private screen is shown when user doesn't have Firefox private pinned but has Firefox pinned + Assert.ok( + getOnboardingScreenById( + message.content.screens, + "UPGRADE_PIN_PRIVATE_WINDOW" + ) + ); + sandbox.restore(); + } +); + +add_task( + async function test_OnboardingMessageProvider_getNoPinPrivateWindow_noPin() { + Services.prefs.setBoolPref("browser.shell.checkDefaultBrowser", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.shell.checkDefaultBrowser"); + }); + let sandbox = sinon.createSandbox(); + // User needs default to ensure Pin Private window shows as third screen after import + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(true); + + let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin"); + pinStub.resolves(true); + const message = await OnboardingMessageProvider.getUpgradeMessage(); + + // Pin Private screen is not shown when user doesn't have Firefox pinned + Assert.ok( + !getOnboardingScreenById( + message.content.screens, + "UPGRADE_PIN_PRIVATE_WINDOW" + ) + ); + sandbox.restore(); + } +); + +add_task(async function test_schemaValidation() { + const { experimentValidator, messageValidators } = await makeValidators(); + + const messages = await OnboardingMessageProvider.getMessages(); + for (const message of messages) { + const validator = messageValidators[message.template]; + + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}.` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + assertValidates( + validator, + message, + `Message ${message.id} validates as template ${message.template}` + ); + assertValidates( + experimentValidator, + message, + `Message ${message.id} validates as MessagingExperiment` + ); + } +}); + +add_task( + async function test_OnboardingMessageProvider_getPinPrivateWindow_pinPBMPrefDisabled() { + Services.prefs.setBoolPref( + "browser.startup.upgradeDialog.pinPBM.disabled", + true + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref( + "browser.startup.upgradeDialog.pinPBM.disabled" + ); + }); + let sandbox = sinon.createSandbox(); + // User needs default to ensure Pin Private window shows as third screen after import + sandbox + .stub(OnboardingMessageProvider, "_doesAppNeedDefault") + .resolves(true); + + let pinStub = sandbox.stub(OnboardingMessageProvider, "_doesAppNeedPin"); + pinStub.resolves(true); + + const message = await OnboardingMessageProvider.getUpgradeMessage(); + // Pin Private screen is not shown when pref is turned on + Assert.ok( + !getOnboardingScreenById( + message.content.screens, + "UPGRADE_PIN_PRIVATE_WINDOW" + ) + ); + sandbox.restore(); + } +); diff --git a/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js b/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js new file mode 100644 index 0000000000..3523355659 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_PanelTestProvider.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/PanelTestProvider.sys.mjs" +); + +const MESSAGE_VALIDATORS = {}; +let EXPERIMENT_VALIDATOR; + +add_setup(async function setup() { + const validators = await makeValidators(); + + EXPERIMENT_VALIDATOR = validators.experimentValidator; + Object.assign(MESSAGE_VALIDATORS, validators.messageValidators); +}); + +add_task(async function test_PanelTestProvider() { + const messages = await PanelTestProvider.getMessages(); + + const EXPECTED_MESSAGE_COUNTS = { + cfr_doorhanger: 1, + milestone_message: 0, + update_action: 1, + whatsnew_panel_message: 7, + spotlight: 3, + feature_callout: 1, + pb_newtab: 2, + toast_notification: 3, + }; + + const EXPECTED_TOTAL_MESSAGE_COUNT = Object.values( + EXPECTED_MESSAGE_COUNTS + ).reduce((a, b) => a + b, 0); + + Assert.strictEqual( + messages.length, + EXPECTED_TOTAL_MESSAGE_COUNT, + "PanelTestProvider should have the correct number of messages" + ); + + const messageCounts = Object.assign( + {}, + ...Object.keys(EXPECTED_MESSAGE_COUNTS).map(key => ({ [key]: 0 })) + ); + + for (const message of messages) { + const validator = MESSAGE_VALIDATORS[message.template]; + Assert.ok( + typeof validator !== "undefined", + typeof validator !== "undefined" + ? `Schema validator found for ${message.template}` + : `No schema validator found for template ${message.template}. Please update this test to add one.` + ); + assertValidates( + validator, + message, + `Message ${message.id} validates as ${message.template} template` + ); + assertValidates( + EXPERIMENT_VALIDATOR, + message, + `Message ${message.id} validates as MessagingExperiment` + ); + + messageCounts[message.template]++; + } + + for (const [template, count] of Object.entries(messageCounts)) { + Assert.equal( + count, + EXPECTED_MESSAGE_COUNTS[template], + `Expected ${EXPECTED_MESSAGE_COUNTS[template]} ${template} messages` + ); + } +}); + +add_task(async function test_emptyMessage() { + info( + "Testing blank FxMS messages validate with the Messaging Experiment schema" + ); + + assertValidates(EXPERIMENT_VALIDATOR, {}, "Empty message should validate"); +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_reach_experiments.js b/browser/components/asrouter/tests/xpcshell/test_reach_experiments.js new file mode 100644 index 0000000000..e69ce98677 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_reach_experiments.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ObjectUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ObjectUtils.sys.mjs" +); + +const MESSAGES = [ + { + trigger: { id: "defaultBrowserCheck" }, + targeting: + "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5", + }, + { + groups: ["eco"], + trigger: { + id: "defaultBrowserCheck", + }, + targeting: + "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5", + }, +]; + +let EXPERIMENT_VALIDATOR; + +add_setup(async function setup() { + EXPERIMENT_VALIDATOR = await schemaValidatorFor( + "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json" + ); +}); + +add_task(function test_reach_experiments_validation() { + for (const [index, message] of MESSAGES.entries()) { + assertValidates( + EXPERIMENT_VALIDATOR, + message, + `Message ${index} validates as a MessagingExperiment` + ); + } +}); + +function depError(has, missing) { + return { + instanceLocation: "#", + keyword: "dependentRequired", + keywordLocation: "#/oneOf/1/allOf/0/$ref/dependantRequired", + error: `Instance has "${has}" but does not have "${missing}".`, + }; +} + +function assertContains(haystack, needle) { + Assert.ok( + haystack.find(item => ObjectUtils.deepEqual(item, needle)) !== null + ); +} + +add_task(function test_reach_experiment_dependentRequired() { + info( + "Testing that if id is present then content and template are not required" + ); + + { + const message = { + ...MESSAGES[0], + id: "message-id", + }; + + const result = EXPERIMENT_VALIDATOR.validate(message); + Assert.ok(result.valid, "message should validate"); + } + + info("Testing that if content is present then id and template are required"); + { + const message = { + ...MESSAGES[0], + content: {}, + }; + + const result = EXPERIMENT_VALIDATOR.validate(message); + Assert.ok(!result.valid, "message should not validate"); + assertContains(result.errors, depError("content", "id")); + assertContains(result.errors, depError("content", "template")); + } + + info("Testing that if template is present then id and content are required"); + { + const message = { + ...MESSAGES[0], + template: "cfr", + }; + + const result = EXPERIMENT_VALIDATOR.validate(message); + Assert.ok(!result.valid, "message should not validate"); + assertContains(result.errors, depError("template", "content")); + assertContains(result.errors, depError("template", "id")); + } +}); diff --git a/browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js b/browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js new file mode 100644 index 0000000000..40c0993b4f --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/test_remoteExperiments.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); + +add_task(async function test_multiMessageTreatment() { + const { experimentValidator } = await makeValidators(); + // Use the entire list of messages as if it was a single treatment branch's + // feature value. + let messages = await CFRMessageProvider.getMessages(); + let featureValue = { template: "multi", messages }; + assertValidates( + experimentValidator, + featureValue, + `Multi-message treatment validates as MessagingExperiment` + ); + for (const message of messages) { + assertValidates( + experimentValidator, + message, + `Message ${message.id} validates as MessagingExperiment` + ); + } + + // Add an invalid message to the list and make sure it fails validation. + messages.push({ + id: "INVALID_MESSAGE", + template: "cfr_doorhanger", + }); + const result = experimentValidator.validate(featureValue); + Assert.ok( + !(result.valid && result.errors.length === 0), + "Multi-message treatment with invalid message fails validation" + ); +}); diff --git a/browser/components/asrouter/tests/xpcshell/xpcshell.toml b/browser/components/asrouter/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..db28042ad2 --- /dev/null +++ b/browser/components/asrouter/tests/xpcshell/xpcshell.toml @@ -0,0 +1,24 @@ +[DEFAULT] +head = "head.js" +firefox-appdir = "browser" + +["test_ASRouterTargeting_attribution.js"] +run-if = ["os == 'mac'"] # osx specific tests + +["test_ASRouterTargeting_snapshot.js"] + +["test_ASRouter_getTargetingParameters.js"] + +["test_CFRMessageProvider.js"] + +["test_InflightAssetsMessageProvider.js"] + +["test_NimbusRolloutMessageProvider.js"] + +["test_OnboardingMessageProvider.js"] + +["test_PanelTestProvider.js"] + +["test_reach_experiments.js"] + +["test_remoteExperiments.js"] |