diff options
Diffstat (limited to 'toolkit/components/messaging-system/schemas/TriggerActionSchemas')
5 files changed, 1134 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json new file mode 100644 index 0000000000..6a7d2328d7 --- /dev/null +++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/TriggerActionSchemas.json @@ -0,0 +1,297 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/TriggerActionSchemas", + "definitions": { + "TriggerActionSchemas": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["openURL"] + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of urls we should match against" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of Match pattern compatible strings to match against" + } + }, + "required": ["id"], + "additionalProperties": false, + "description": "Happens every time the user loads a new URL that matches the provided `hosts` or `patterns`" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["openArticleURL"] + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of urls we should match against" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of Match pattern compatible strings to match against" + } + }, + "required": ["id"], + "additionalProperties": false, + "description": "Happens every time the user loads a document that is Reader Mode compatible" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["openBookmarkedURL"] + } + }, + "required": ["id"], + "additionalProperties": false, + "description": "Happens every time the user adds a bookmark from the URL bar star icon" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["frequentVisits"] + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of urls we should match against" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of Match pattern compatible strings to match against" + } + }, + "required": ["id"], + "additionalProperties": false, + "description": "Happens every time a user navigates (or switches tab to) to any of the `hosts` or `patterns` arguments but additionally provides information about the number of accesses to the matched domain." + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["newSavedLogin"] + } + }, + "required": ["id"], + "additionalProperties": false, + "description": "Happens every time the user adds or updates a login" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["formAutofill"] + } + }, + "required": ["id"], + "additionalProperties": false, + "description": "Happens when the user saves, updates, or uses a credit card or address for form autofill" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["contentBlocking"] + }, + "params": { + "type": "array", + "items": { + "type": ["string", "integer"], + "description": "Events that should trigger this message. String values correspond to ContentBlockingMilestone events and number values correspond to STATE_BLOCKED_* flags on nsIWebProgressListener." + } + } + }, + "required": ["id", "params"], + "additionalProperties": false, + "description": "Happens every time Firefox blocks the loading of a page script/asset/resource that matches the one of the tracking behaviours specifid through params. See https://searchfox.org/mozilla-central/rev/8ccea36c4fb09412609fb738c722830d7098602b/uriloader/base/nsIWebProgressListener.idl#336" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["defaultBrowserCheck"] + }, + "context": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": ["newtab"], + "description": "When the source of the trigger is home/newtab" + }, + "willShowDefaultPrompt": { + "type": "boolean", + "description": "When the source of the trigger is startup" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Happens when starting the browser or navigating to about:home/newtab" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["captivePortalLogin"] + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Happens when the user successfully goes through a captive portal authentication flow." + }, + { + "description": "Notify when a preference is added, removed or modified", + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["preferenceObserver"] + }, + "params": { + "type": "array", + "items": { + "type": "string", + "description": "Preference names to observe." + } + } + }, + "additionalProperties": false, + "required": ["id", "params"] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["featureCalloutCheck"] + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Used to display Feature Callouts in Firefox View. Can only be used for Feature Callouts." + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["pdfJsFeatureCalloutCheck"] + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Used to display Feature Callouts on PDF.js pages. Can only be used for Feature Callouts." + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["nthTabClosed"] + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Happens when the user closes n or more tabs in a session" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["activityAfterIdle"] + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Happens when the user resumes activity after n milliseconds of inactivity (keyboard/mouse interactions and audio playback all count as activity)" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["cookieBannerDetected"] + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Happens when Firefox detects a cookie consent banner that could otherwise be handled by Cookie Banner Handling" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["cookieBannerHandled"] + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Happens when Firefox automatically engages with a cookie consent banner" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["messagesLoaded"] + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Happens as soon as a message is loaded" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["pageActionInUrlbar"] + } + }, + "additionalProperties": false, + "required": ["id"], + "description": "Happens when a page action appears in the urlbar. The specific page action(s) to watch can be specified by id in the targeting expression." + } + ] + } + } +} diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md new file mode 100644 index 0000000000..0d2f6dc89b --- /dev/null +++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/index.md @@ -0,0 +1,260 @@ +# Trigger Listeners + +A set of action listeners that can be used to trigger CFR messages. + +## Usage + +[As part of the CFR definition](https://searchfox.org/mozilla-central/rev/2bfe3415fb3a2fba9b1c694bc0b376365e086927/browser/components/newtab/lib/CFRMessageProvider.jsm#194) the message can register at most one trigger used to decide when the message is shown. + +Most triggers (unless otherwise specified) take the same arguments of `hosts` and/or `patterns` +used to target the message to specific websites. + +```javascript +// Optional set of hosts to filter out triggers only to certain websites +let params: string[]; +// Optional set of [match patterns](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns) to filter out triggers only to certain websites +let patterns: string[]; +``` + +```javascript +{ + ... + // Show the message when opening mozilla.org + "trigger": { "id": "openURL", "params": ["mozilla.org", "www.mozilla.org"] } + ... +} +``` + +```javascript +{ + ... + // Show the message when opening any HTTP, HTTPS URL. + trigger: { id: "openURL", patterns: ["*://*/*"] } + ... +} +``` + +## Available trigger actions + +* [openArticleURL](#openarticleurl) +* [openBookmarkedURL](#openbookmarkedurl) +* [frequentVisits](#frequentvisits) +* [openURL](#openurl) +* [newSavedLogin](#newsavedlogin) +* [formAutofill](#formautofill) +* [contentBlocking](#contentblocking) +* [defaultBrowserCheck](#defaultbrowsercheck) +* [captivePortalLogin](#captiveportallogin) +* [preferenceObserver](#preferenceobserver) +* [featureCalloutCheck](#featurecalloutcheck) +* [nthTabClosed](#nthtabclosed) +* [activityAfterIdle](#activityafteridle) +* [cookieBannerDetected](#cookiebannerdetected) +* [cookieBannerHandled](#cookiebannerhandled) +* [messagesLoaded](#messagesloaded) + +### `openArticleURL` + +Happens when the user loads a Reader Mode compatible webpage. + +### `openBookmarkedURL` + +Happens when the user bookmarks or navigates to a bookmarked URL. + +Does not filter by host or patterns. + +### `frequentVisits` + +Happens every time a user navigates (or switches tab to) to any of the `hosts` or `patterns` arguments +provided. Additionally it stores timestamps of these visits that are provided back to the targeting context. +They can be used inside of the targeting expression: + +```javascript +// Has at least 3 visits in the past hour +recentVisits[.timestamp > (currentDate|date - 3600 * 1000 * 1)]|length >= 3 + +``` + +```typescript +interface visit { + host: string, + timestamp: UnixTimestamp +}; +// Host and timestamp for every visit to "Host" +let recentVisits: visit[]; +``` + +### `openURL` + +Happens every time the user loads a new URL that matches the provided `hosts` or `patterns`. +During a browsing session it keeps track of visits to unique urls that can be used inside targeting expression. + +```javascript +// True on the third visit for the URL which the trigger matched on +visitsCount >= 3 +``` + +### `newSavedLogin` + +Happens every time the user saves or updates a login via the login capture doorhanger. +Provides a `type` to diferentiate between the two events that can be used in targeting. + +Does not filter by host or patterns. + +```typescript +let type = "update" | "save"; +``` + +### `formAutofill` + +Happens when the user saves, updates, or uses a credit card or address for form +autofill. To reduce the trigger's disruptiveness, it does not fire when the user +is manually editing these items in the manager in about:preferences. For the +same reason, the trigger only fires after a 10-second delay. The trigger context +includes an `event` and `type` that can be used in targeting. Possible events +include `add`, `update`, and `use`. Possible types are `card` and `address`. +This trigger is especially intended to be used in tandem with the +`creditCardsSaved` and `addressesSaved` [targeting attributes](../../../../../browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md). + +```js +{ + trigger: { id: "formAutofill" }, + targeting: "type == 'card' && event in ['add', 'update']" +} +``` + +### `contentBlocking` + +Happens at the and of a document load and for every subsequent content blocked event, or when the tracking DB service hits a milestone. + +Provides a context of the number of pages loaded in the current browsing session that can be used in targeting. + +Does not filter by host or patterns. + +The event it reports back is one of two things: + * A combination of OR-ed [nsIWebProgressListener](https://searchfox.org/mozilla-central/source/uriloader/base/nsIWebProgressListener.idl) `STATE_BLOCKED_*` flags + * A string constants, such as [`"ContentBlockingMilestone"`](https://searchfox.org/mozilla-central/rev/8a2d8d26e25ef70c98c6036612aad534b76b9815/toolkit/components/antitracking/TrackingDBService.jsm#327-334) + + +### `defaultBrowserCheck` + +Happens at startup, when opening a newtab and when navigating to about:home. +At startup, it reports the `source` as `startup`, and it provides a context +attribute `willShowDefaultPrompt` that can be used in targeting to avoid showing +a message when the built-in default browser prompt is going to be displayed. +This is important to avoid the negative UX of showing two promts back-to-back, +especially if both prompts offer similar affordances. +On the newtab/homepage, it reports the `source` as `newtab`. + +```ts +let source = "startup" | "newtab"; +let willShowDefaultPrompt = boolean | undefined; +``` + +#### Examples +* Only trigger on startup, not on newtab/homepage +* Don't show if the built-in prompt is going to be shown +```js +{ + trigger: { id: "defaultBrowserCheck" }, + targeting: "source == 'startup' && !willShowDefaultPrompt" +} +``` + +### `captivePortalLogin` + +Happens when the user successfully goes through a captive portal authentication flow. + +### `preferenceObserver` + +Watch for changes on any number of preferences. Runs when a pref is added, removed or modified. + +```js +// Register a message with the following trigger +{ + id: "preferenceObserver", + params: ["pref name"] +} +``` + +### `featureCalloutCheck` + +Used to display Feature Callouts in Firefox View. Can only be used for Feature Callouts. + +### `pdfJsFeatureCalloutCheck` + +Used to display Feature Callouts on PDF.js pages. Can only be used for Feature Callouts. + +### `newtabFeatureCalloutCheck` + +Used to display Feature Callouts on about:newtab. Can only be used for Feature Callouts. + +### `nthTabClosed` + +Happens when the user closes n or more tabs in a session + +```js +// Register a message with the following trigger and +// include the tabsClosedCount context variable in the targeting. +// Here, the message triggers after two or more tabs are closed. +{ + trigger: { id: "nthTabClosed" }, + targeting: "tabsClosedCount >= 2" +} +``` + +### `activityAfterIdle` + +Happens when the user resumes activity after n milliseconds of inactivity. Keyboard/mouse interactions and audio playback count as activity. The idle timer is reset when the OS is put to sleep or wakes from sleep. + +No params or patterns. The `idleForMilliseconds` context variable is available in targeting. This value represents the number of milliseconds since the last user interaction or audio playback. `60000` is the minimum value for this variable (1 minute). In the following example, the message triggers when the user returns after at least 20 minutes of inactivity. + +```js +// Register a message with the following trigger and include +// the idleForMilliseconds context variable in the targeting. +{ + trigger: { id: "activityAfterIdle" }, + targeting: "idleForMilliseconds >= 1200000" +} +``` + +### `cookieBannerDetected` + +Happens when the `cookiebannerdetected` window event is dispatched. This event is dispatched when the following conditions are true: + +1. The user is presented with a cookie consent banner on the webpage they're viewing, +2. The domain has a valid ruleset for automatically engaging with the consent banner, and +3. The user has not explicitly opted in or out of the Cookie Banner Handling feature. + +### `cookieBannerHandled` + +Happens when the `cookiebannerhandled` window event is dispatched. This event is dispatched when the following conditions are true: + +1. The user is presented with a cookie consent banner on the webpage they're viewing, +2. The domain has a valid ruleset for automatically engaging with the consent banner, and +3. The user is opted into the Cookie Banner Handling feature (this is by default in private windows), and +4. Firefox succeeds in automatically engaging with the consent banner. + +### `messagesLoaded` + +Happens as soon as a message is loaded. This trigger does not require any user interaction, and may happen potentially as early as app launch, or at some time after experiment enrollment. Generally intended for use in reach experiments, because most messages cannot be routed unless the surfaces they display in are instantiated in a tabbed browser window (a reach message will not be displayed but its trigger will still be recorded). However, it is still possible to safely use this trigger for a normal message, with some caveats. This is potentially relevant on macOS, where the app can be running with no browser windows open, or even on Windows, where closing all browser windows but leaving open a non-browser window (e.g. the Library) causes the app to remain running. + +A `toast_notification` or `update_action` message can function normally under these circumstances. A `toolbar_badge` message will load with or without a window, but will not actually display until a window exists. But messages with templates like `infobar` will have no effect unless a window exists to display them in. Any message using this trigger, regardless of template, can exclude window-less or browser-less contexts by adding the following targeting. This isn't strictly necessary because the messaging surfaces will either work normally or fail gracefully, but it may be desirable to test reach only in certain contexts, so the context objects `browser` and `browserWindow` are provided, corresponding to the selected browser (`gBrowser.selectedBrowser`) and the most recently active chrome window, respectively. + +```js +{ + trigger: { id: "messagesLoaded" }, + targeting: "browser && browserWindow" +} +``` + +### `pageActionInUrlbar` + +Happens when a page action appears in the location bar. The specific page action(s) to watch for can be specified by id in the targeting expression. For example, to trigger when the reader mode button appears: + +```js +{ + trigger: { id: "pageActionInUrlbar" }, + targeting: "pageAction == 'reader-mode-button'" +} +``` diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.toml b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.toml new file mode 100644 index 0000000000..18df0a89d8 --- /dev/null +++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser.toml @@ -0,0 +1,7 @@ +[DEFAULT] +support-files = ["../../index.md"] + +["browser_asrouter_trigger_docs.js"] + +["browser_asrouter_trigger_listeners.js"] +https_first_disabled = true diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js new file mode 100644 index 0000000000..e886d06bfe --- /dev/null +++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_docs.js @@ -0,0 +1,74 @@ +const TEST_URL = + "https://example.com/browser/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/index.md"; + +const { ASRouterTriggerListeners } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs" +); +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource:///modules/asrouter/CFRMessageProvider.sys.mjs" +); +const { JsonSchema } = ChromeUtils.importESModule( + "resource://gre/modules/JsonSchema.sys.mjs" +); + +ChromeUtils.defineLazyGetter(this, "fetchTriggerActionSchema", async () => { + const response = await fetch( + "resource://testing-common/TriggerActionSchemas.json" + ); + const schema = await response.json(); + if (!schema) { + throw new Error("Failed to load TriggerActionSchemas"); + } + return schema.definitions.TriggerActionSchemas; +}); + +async function validateTrigger(trigger) { + const schema = await fetchTriggerActionSchema; + const result = JsonSchema.validate(trigger, schema); + if (result.errors.length) { + throw new Error( + `Trigger with id ${trigger.id} was not valid. Errors: ${JSON.stringify( + result.errors, + undefined, + 2 + )}` + ); + } + Assert.equal( + result.errors.length, + 0, + `should be a valid trigger of type ${trigger.id}` + ); +} + +function getHeadingsFromDocs(docs) { + const re = /### `(\w+)`/g; + const found = []; + let match = 1; + while (match) { + match = re.exec(docs); + if (match) { + found.push(match[1]); + } + } + return found; +} + +add_task(async function test_trigger_docs() { + let request = await fetch(TEST_URL, { credentials: "omit" }); + let docs = await request.text(); + let headings = getHeadingsFromDocs(docs); + for (let triggerName of ASRouterTriggerListeners.keys()) { + Assert.ok( + headings.includes(triggerName), + `${triggerName} not found in TriggerActionSchemas/index.md` + ); + } +}); + +add_task(async function test_message_triggers() { + const messages = await CFRMessageProvider.getMessages(); + for (let message of messages) { + await validateTrigger(message.trigger); + } +}); diff --git a/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js new file mode 100644 index 0000000000..816c42775b --- /dev/null +++ b/toolkit/components/messaging-system/schemas/TriggerActionSchemas/test/browser/browser_asrouter_trigger_listeners.js @@ -0,0 +1,496 @@ +ChromeUtils.defineESModuleGetters(this, { + ASRouterTriggerListeners: + "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +async function openURLInWindow(window, url) { + const { selectedBrowser } = window.gBrowser; + BrowserTestUtils.startLoadingURIString(selectedBrowser, url); + await BrowserTestUtils.browserLoaded(selectedBrowser, false, url); +} + +add_task(async function check_matchPatternFailureCase() { + const articleTrigger = ASRouterTriggerListeners.get("openArticleURL"); + + articleTrigger.uninit(); + + articleTrigger.init(() => {}, [], ["example.com"]); + + is( + articleTrigger._matchPatternSet.matches("http://example.com"), + false, + "Should fail, bad pattern" + ); + + articleTrigger.init(() => {}, [], ["*://*.example.com/"]); + + is( + articleTrigger._matchPatternSet.matches("http://www.example.com"), + true, + "Should work, updated pattern" + ); + + articleTrigger.uninit(); +}); + +add_task(async function check_openArticleURL() { + const TEST_URL = + "https://example.com/browser/browser/components/newtab/test/browser/red_page.html"; + const articleTrigger = ASRouterTriggerListeners.get("openArticleURL"); + + // Previously initialized by the Router + articleTrigger.uninit(); + + // Initialize the trigger with a new triggerHandler that resolves a promise + // with the URL match + const listenerTriggered = new Promise(resolve => + articleTrigger.init((browser, match) => resolve(match), ["example.com"]) + ); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(win, TEST_URL); + // Send a message from the content page (the TEST_URL) to the parent + // This should trigger the `receiveMessage` cb in the articleTrigger + await ContentTask.spawn(win.gBrowser.selectedBrowser, null, async () => { + let readerActor = content.windowGlobalChild.getActor("AboutReader"); + readerActor.sendAsyncMessage("Reader:UpdateReaderButton", { + isArticle: true, + }); + }); + + await listenerTriggered.then(data => + is( + data.param.url, + TEST_URL, + "We should match on the TEST_URL as a website article" + ) + ); + + // Cleanup + articleTrigger.uninit(); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function check_openURL_listener() { + const TEST_URL = + "https://example.com/browser/browser/components/newtab/test/browser/red_page.html"; + + let urlVisitCount = 0; + const triggerHandler = () => urlVisitCount++; + const openURLListener = ASRouterTriggerListeners.get("openURL"); + + // Previously initialized by the Router + openURLListener.uninit(); + + const normalWindow = await BrowserTestUtils.openNewBrowserWindow(); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + // Initialise listener + openURLListener.init(triggerHandler, ["example.com"]); + + await openURLInWindow(normalWindow, TEST_URL); + await BrowserTestUtils.waitForCondition( + () => urlVisitCount !== 0, + "Wait for the location change listener to run" + ); + is(urlVisitCount, 1, "should receive page visits from existing windows"); + + await openURLInWindow(normalWindow, "http://www.example.com/abc"); + is(urlVisitCount, 1, "should not receive page visits for different domains"); + + await openURLInWindow(privateWindow, TEST_URL); + is( + urlVisitCount, + 1, + "should not receive page visits from existing private windows" + ); + + const secondNormalWindow = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(secondNormalWindow, TEST_URL); + await BrowserTestUtils.waitForCondition( + () => urlVisitCount === 2, + "Wait for the location change listener to run" + ); + is(urlVisitCount, 2, "should receive page visits from newly opened windows"); + + const secondPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await openURLInWindow(secondPrivateWindow, TEST_URL); + is( + urlVisitCount, + 2, + "should not receive page visits from newly opened private windows" + ); + + // Uninitialise listener + openURLListener.uninit(); + + await openURLInWindow(normalWindow, TEST_URL); + is( + urlVisitCount, + 2, + "should now not receive page visits from existing windows" + ); + + const thirdNormalWindow = await BrowserTestUtils.openNewBrowserWindow(); + await openURLInWindow(thirdNormalWindow, TEST_URL); + is( + urlVisitCount, + 2, + "should now not receive page visits from newly opened windows" + ); + + // Cleanup + const windows = [ + normalWindow, + privateWindow, + secondNormalWindow, + secondPrivateWindow, + thirdNormalWindow, + ]; + await Promise.all(windows.map(win => BrowserTestUtils.closeWindow(win))); +}); + +add_task(async function check_newSavedLogin_save_listener() { + const TEST_URL = + "https://example.com/browser/browser/components/newtab/test/browser/red_page.html"; + + let triggerTypesHandled = { + save: 0, + update: 0, + }; + const triggerHandler = (sub, { id, context }) => { + is(id, "newSavedLogin", "Check trigger id"); + triggerTypesHandled[context.type]++; + }; + const newSavedLoginListener = ASRouterTriggerListeners.get("newSavedLogin"); + + // Previously initialized by the Router + newSavedLoginListener.uninit(); + + // Initialise listener + await newSavedLoginListener.init(triggerHandler); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggerNewSavedPassword(browser) { + Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword"); + await BrowserTestUtils.waitForCondition( + () => triggerTypesHandled.save !== 0, + "Wait for the observer notification to run" + ); + is(triggerTypesHandled.save, 1, "should receive observer notification"); + } + ); + + is(triggerTypesHandled.update, 0, "shouldn't have handled other trigger"); + + // Uninitialise listener + newSavedLoginListener.uninit(); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggerNewSavedPasswordAfterUninit(browser) { + Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword"); + await new Promise(resolve => executeSoon(resolve)); + is( + triggerTypesHandled.save, + 1, + "shouldn't receive obs. notification after uninit" + ); + } + ); +}); + +add_task(async function check_newSavedLogin_update_listener() { + const TEST_URL = + "https://example.com/browser/browser/components/newtab/test/browser/red_page.html"; + + let triggerTypesHandled = { + save: 0, + update: 0, + }; + const triggerHandler = (sub, { id, context }) => { + is(id, "newSavedLogin", "Check trigger id"); + triggerTypesHandled[context.type]++; + }; + const newSavedLoginListener = ASRouterTriggerListeners.get("newSavedLogin"); + + // Previously initialized by the Router + newSavedLoginListener.uninit(); + + // Initialise listener + await newSavedLoginListener.init(triggerHandler); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggerLoginUpdateSaved(browser) { + Services.obs.notifyObservers(browser, "LoginStats:LoginUpdateSaved"); + await BrowserTestUtils.waitForCondition( + () => triggerTypesHandled.update !== 0, + "Wait for the observer notification to run" + ); + is(triggerTypesHandled.update, 1, "should receive observer notification"); + } + ); + + is(triggerTypesHandled.save, 0, "shouldn't have handled other trigger"); + + // Uninitialise listener + newSavedLoginListener.uninit(); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggerLoginUpdateSavedAfterUninit(browser) { + Services.obs.notifyObservers(browser, "LoginStats:LoginUpdateSaved"); + await new Promise(resolve => executeSoon(resolve)); + is( + triggerTypesHandled.update, + 1, + "shouldn't receive obs. notification after uninit" + ); + } + ); +}); + +add_task(async function check_contentBlocking_listener() { + const TEST_URL = + "https://example.com/browser/browser/components/newtab/test/browser/red_page.html"; + + const event1 = 0x0001; + const event2 = 0x0010; + const event3 = 0x0100; + const event4 = 0x1000; + + // Initialise listener to listen 2 events, for any incoming event e, + // it will be triggered if and only if: + // 1. (e & event1) && (e & event2) + // 2. (e & event3) + const bindEvents = [event1 | event2, event3]; + + let observerEvent = 0; + let pageLoadSum = 0; + const triggerHandler = (target, trigger) => { + const { + id, + param: { host, type }, + context: { pageLoad }, + } = trigger; + is(id, "contentBlocking", "should match event name"); + is(host, TEST_URL, "should match test URL"); + is( + bindEvents.filter(e => (type & e) === e).length, + 1, + `event ${type} is valid` + ); + Assert.lessOrEqual(pageLoadSum, pageLoad, "pageLoad is non-decreasing"); + + observerEvent += 1; + pageLoadSum = pageLoad; + }; + const contentBlockingListener = + ASRouterTriggerListeners.get("contentBlocking"); + + // Previously initialized by the Router + contentBlockingListener.uninit(); + + await contentBlockingListener.init(triggerHandler, bindEvents); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggercontentBlocking(browser) { + Services.obs.notifyObservers( + { + wrappedJSObject: { + browser, + host: TEST_URL, + event: event1, // won't trigger + }, + }, + "SiteProtection:ContentBlockingEvent" + ); + } + ); + + is(observerEvent, 0, "shouldn't receive unrelated observer notification"); + is(pageLoadSum, 0, "shouldn't receive unrelated observer notification"); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggercontentBlocking(browser) { + Services.obs.notifyObservers( + { + wrappedJSObject: { + browser, + host: TEST_URL, + event: event3, // will trigger + }, + }, + "SiteProtection:ContentBlockingEvent" + ); + + await BrowserTestUtils.waitForCondition( + () => observerEvent !== 0, + "Wait for the observer notification to run" + ); + is(observerEvent, 1, "should receive observer notification"); + is(pageLoadSum, 2, "should receive observer notification"); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + browser, + host: TEST_URL, + event: event1 | event2 | event4, // still trigger + }, + }, + "SiteProtection:ContentBlockingEvent" + ); + + await BrowserTestUtils.waitForCondition( + () => observerEvent !== 1, + "Wait for the observer notification to run" + ); + is(observerEvent, 2, "should receive another observer notification"); + is(pageLoadSum, 2, "should receive another observer notification"); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + browser, + host: TEST_URL, + event: event1, // no trigger + }, + }, + "SiteProtection:ContentBlockingEvent" + ); + + await new Promise(resolve => executeSoon(resolve)); + is(observerEvent, 2, "shouldn't receive unrelated notification"); + is(pageLoadSum, 2, "shouldn't receive unrelated notification"); + } + ); + + // Uninitialise listener + contentBlockingListener.uninit(); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggercontentBlockingAfterUninit(browser) { + Services.obs.notifyObservers( + { + wrappedJSObject: { + browser, + host: TEST_URL, + event: event3, // wont trigger after uninit + }, + }, + "SiteProtection:ContentBlockingEvent" + ); + await new Promise(resolve => executeSoon(resolve)); + is(observerEvent, 2, "shouldn't receive obs. notification after uninit"); + is(pageLoadSum, 2, "shouldn't receive obs. notification after uninit"); + } + ); +}); + +add_task(async function check_contentBlockingMilestone_listener() { + const TEST_URL = + "https://example.com/browser/browser/components/newtab/test/browser/red_page.html"; + + let observerEvent = 0; + const triggerHandler = (target, trigger) => { + const { + id, + param: { type }, + } = trigger; + is(id, "contentBlocking", "should match event name"); + is(type, "ContentBlockingMilestone", "Should be the correct event type"); + observerEvent += 1; + }; + const contentBlockingListener = + ASRouterTriggerListeners.get("contentBlocking"); + + // Previously initialized by the Router + contentBlockingListener.uninit(); + + // Initialise listener + contentBlockingListener.init(triggerHandler, ["ContentBlockingMilestone"]); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggercontentBlocking(browser) { + Services.obs.notifyObservers( + { + wrappedJSObject: { + browser, + event: "Other Event", + }, + }, + "SiteProtection:ContentBlockingMilestone" + ); + } + ); + + is(observerEvent, 0, "shouldn't receive unrelated observer notification"); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggercontentBlocking(browser) { + Services.obs.notifyObservers( + { + wrappedJSObject: { + browser, + event: "ContentBlockingMilestone", + }, + }, + "SiteProtection:ContentBlockingMilestone" + ); + + await BrowserTestUtils.waitForCondition( + () => observerEvent !== 0, + "Wait for the observer notification to run" + ); + is(observerEvent, 1, "should receive observer notification"); + } + ); + + // Uninitialise listener + contentBlockingListener.uninit(); + + await BrowserTestUtils.withNewTab( + TEST_URL, + async function triggercontentBlockingAfterUninit(browser) { + Services.obs.notifyObservers( + { + wrappedJSObject: { + browser, + event: "ContentBlockingMilestone", + }, + }, + "SiteProtection:ContentBlockingMilestone" + ); + await new Promise(resolve => executeSoon(resolve)); + is(observerEvent, 1, "shouldn't receive obs. notification after uninit"); + } + ); +}); + +add_task(function test_pattern_match() { + const openURLListener = ASRouterTriggerListeners.get("openURL"); + openURLListener.uninit(); + openURLListener.init(() => {}, [], ["*://*/*.pdf"]); + let pattern = openURLListener._matchPatternSet; + + Assert.ok(pattern.matches("https://example.com/foo.pdf"), "match 1"); + Assert.ok(pattern.matches("https://example.com/bar/foo.pdf"), "match 2"); + Assert.ok(pattern.matches("https://www.example.com/foo.pdf"), "match 3"); + // Shouldn't match. Too generic. + Assert.ok(!pattern.matches("https://www.example.com/foo"), "match 4"); + Assert.ok(!pattern.matches("https://www.example.com/pdf"), "match 5"); +}); |