diff --git a/shared/js/background/background.js b/shared/js/background/background.js index 8bdc5bc6a..eda6b256d 100644 --- a/shared/js/background/background.js +++ b/shared/js/background/background.js @@ -32,7 +32,9 @@ import RemoteConfig from './components/remote-config'; import initDebugBuild from './devbuild'; import initReloader from './devbuild-reloader'; import tabManager from './tab-manager'; -import AbnExperimentMetrics, { AppUseMetric, PixelMetric, SearchMetric } from './components/abn-experiments'; +import AbnExperimentMetrics from './components/abn-experiments'; +import MessageRouter from './components/message-router'; +import { AppUseMetric, SearchMetric, DashboardUseMetric, RefreshMetric } from './metrics'; // NOTE: this needs to be the first thing that's require()d when the extension loads. // otherwise FF might miss the onInstalled event require('./events'); @@ -60,6 +62,7 @@ const devtools = new Devtools({ tds }); * trackers: TrackersGlobal; * remoteConfig: RemoteConfig; * abnMetrics: AbnExperimentMetrics?; + * messaging: MessageRouter; * }} */ const components = { @@ -74,11 +77,20 @@ const components = { devtools, remoteConfig, abnMetrics, + messaging: new MessageRouter({ tabManager }), }; // Chrome-only components if (BUILD_TARGET === 'chrome' || BUILD_TARGET === 'chrome-mv2') { - components.metrics = [new AppUseMetric({ abnMetrics }), new SearchMetric({ abnMetrics }), new PixelMetric({ abnMetrics })]; + components.metrics = [ + new AppUseMetric({ abnMetrics }), + new SearchMetric({ abnMetrics }), + new DashboardUseMetric({ abnMetrics, messaging: components.messaging }), + new RefreshMetric({ + abnMetrics, + tabTracking: components.tabTracking, + }), + ]; components.fireButton = new FireButton({ settings, tabManager }); } // MV3-only components diff --git a/shared/js/background/before-request.js b/shared/js/background/before-request.js index d53bb8326..40d40883e 100644 --- a/shared/js/background/before-request.js +++ b/shared/js/background/before-request.js @@ -1,7 +1,7 @@ import browser from 'webextension-polyfill'; import EventEmitter2 from 'eventemitter2'; import ATB from './atb'; -import { postPopupMessage } from './popupMessaging'; +import { postPopupMessage } from './popup-messaging'; const utils = require('./utils'); const trackers = require('./trackers'); diff --git a/shared/js/background/companies.js b/shared/js/background/companies.js index d7a58cf84..557f62b4e 100644 --- a/shared/js/background/companies.js +++ b/shared/js/background/companies.js @@ -1,7 +1,7 @@ const TopBlocked = require('./classes/top-blocked'); const Company = require('./classes/company'); const browserWrapper = require('./wrapper'); -const { postPopupMessage } = require('./popupMessaging'); +const { postPopupMessage } = require('./popup-messaging'); const Companies = (() => { let companyContainer = {}; diff --git a/shared/js/background/components/abn-experiments.js b/shared/js/background/components/abn-experiments.js index 4c3b9c42b..ddb3f4523 100644 --- a/shared/js/background/components/abn-experiments.js +++ b/shared/js/background/components/abn-experiments.js @@ -11,7 +11,6 @@ * sent: boolean; * } & ExperimentMetric} ExperimentMetricCounter */ -import browser from 'webextension-polyfill'; import { sendPixelRequest } from '../pixels'; /** @@ -47,69 +46,6 @@ export function generateRetentionMetrics() { }); } -export class AppUseMetric { - /** - * Metric that fires once on creation. - * @param {{ - * abnMetrics: AbnExperimentMetrics - * }} opts - */ - constructor({ abnMetrics }) { - // trigger on construction: happens whenever the service worker is spun up, which should correlate with browser activity. - abnMetrics.remoteConfig.ready.then(() => abnMetrics.onMetricTriggered('app_use')); - } -} - -export class SearchMetric { - /** - * Metric that fires whenever a new search is made - * @param {{ - * abnMetrics: AbnExperimentMetrics - * }} opts - */ - constructor({ abnMetrics }) { - browser.webRequest.onCompleted.addListener( - async (details) => { - const params = new URL(details.url).searchParams; - if (params.get('q')?.length) { - await abnMetrics.remoteConfig.ready; - abnMetrics.onMetricTriggered('search'); - } - }, - { - urls: ['https://*.duckduckgo.com/*'], - types: ['main_frame'], - }, - ); - } -} - -export class PixelMetric { - /** - * Metric that observes outgoing pixel calls and routes a subset of them as experiment metrics. - * Currently intercepts `epbf` pixels and triggers a `brokenSiteReport` metric. - * @param {{ - * abnMetrics: AbnExperimentMetrics - * }} opts - */ - constructor({ abnMetrics }) { - browser.webRequest.onCompleted.addListener( - async (details) => { - await abnMetrics.remoteConfig.ready; - const url = new URL(details.url); - if (url.pathname.startsWith('/t/epbf_')) { - // broken site report - abnMetrics.onMetricTriggered('brokenSiteReport'); - } - }, - { - urls: ['https://improving.duckduckgo.com/t/*'], - tabId: -1, - }, - ); - } -} - /** * A/B/N testing framework: metrics. * diff --git a/shared/js/background/components/message-router.js b/shared/js/background/components/message-router.js new file mode 100644 index 000000000..717d92c2f --- /dev/null +++ b/shared/js/background/components/message-router.js @@ -0,0 +1,119 @@ +import browser from 'webextension-polyfill'; + +import messageHandlers from '../message-handlers'; +import { getExtensionId } from '../wrapper'; +import { getBrowserName } from '../utils'; +import { getActivePort, setActivePort } from '../popup-messaging'; + +/** + * @typedef {import('webextension-polyfill').Runtime.Port} Port + * @typedef {import('@duckduckgo/privacy-dashboard/schema/__generated__/schema.types').IncomingExtensionMessage} OutgoingPopupMessage + */ + +export class MessageReceivedEvent extends CustomEvent { + constructor(msg) { + super('messageReceived', { detail: msg }); + } + + get messageType() { + return this.detail.messageType; + } +} + +export default class MessageRouter extends EventTarget { + constructor({ tabManager }) { + super(); + const browserName = getBrowserName(); + // Handle popup UI (aka privacy dashboard) messaging. + browser.runtime.onConnect.addListener((port) => { + if (port.name === 'privacy-dashboard') { + this.popupConnectionOpened(port); + } + }); + + // Handle any messages that come from content/UI scripts + browser.runtime.onMessage.addListener((req, sender) => { + if (sender.id !== getExtensionId()) return; + + // TODO clean up message passing + const legacyMessageTypes = [ + 'addUserData', + 'getUserData', + 'removeUserData', + 'getEmailProtectionCapabilities', + 'getAddresses', + 'refreshAlias', + 'debuggerMessage', + ]; + for (const legacyMessageType of legacyMessageTypes) { + if (legacyMessageType in req) { + req.messageType = legacyMessageType; + req.options = req[legacyMessageType]; + } + } + + if (req.registeredTempAutofillContentScript) { + req.messageType = 'registeredContentScript'; + } + + if (req.messageType && req.messageType in messageHandlers) { + this.dispatchEvent(new MessageReceivedEvent(req)); + return Promise.resolve(messageHandlers[req.messageType](req.options, sender, req)); + } + + // Count refreshes per page + if (req.pageReloaded && sender.tab !== undefined) { + const tab = tabManager.get({ tabId: sender.tab.id }); + if (tab) { + tab.userRefreshCount += 1; + } + return; + } + + // TODO clean up legacy onboarding messaging + if (browserName === 'chrome') { + if (req === 'healthCheckRequest' || req === 'rescheduleCounterMessagingRequest') { + return; + } + } + + console.error('Unrecognized message to background:', req, sender); + }); + } + + /** + * Set up the messaging connection with the popup UI. + * + * Note: If the ServiceWorker dies while there is an open connection, the popup + * will take care of re-opening the connection. + * + * @param {Port} port + */ + popupConnectionOpened(port) { + setActivePort(port); + port.onDisconnect.addListener(() => { + if (getActivePort() === port) { + setActivePort(null); + } + }); + + port.onMessage.addListener(async (message) => { + const messageType = message?.messageType; + + if (!messageType || !(messageType in messageHandlers)) { + console.error('Unrecognized message (privacy-dashboard -> background):', message); + return; + } + this.dispatchEvent(new MessageReceivedEvent(message)); + + const response = await messageHandlers[messageType](message?.options, port, message); + if (typeof message?.id === 'number') { + port.postMessage({ + id: message.id, + messageType: 'response', + options: response, + }); + } + }); + } +} diff --git a/shared/js/background/components/tab-tracking.js b/shared/js/background/components/tab-tracking.js index e11a2ed1f..e35c8b527 100644 --- a/shared/js/background/components/tab-tracking.js +++ b/shared/js/background/components/tab-tracking.js @@ -9,7 +9,7 @@ import { isRedirect } from '../utils'; * @typedef {import('../tab-manager.js')} TabManager */ -export default class TabTracker { +export default class TabTracker extends EventTarget { /** * @param {{ * tabManager: TabManager; @@ -17,6 +17,7 @@ export default class TabTracker { * }} options */ constructor({ tabManager, devtools }) { + super(); this.tabManager = tabManager; this.createdTargets = new Map(); @@ -68,6 +69,9 @@ export default class TabTracker { const currentTab = tabManager.get({ tabId: details.tabId }); const newTab = tabManager.create({ tabId: details.tabId, url: details.url }); + if (currentTab && currentTab.site.url === details.url) { + this.dispatchEvent(new CustomEvent('tabRefresh', { detail: details })); + } if (BUILD_TARGET === 'chrome') { // Ensure that the correct declarativeNetRequest allowing rules are diff --git a/shared/js/background/components/tds.js b/shared/js/background/components/tds.js index 2e75ff79e..3720e9028 100644 --- a/shared/js/background/components/tds.js +++ b/shared/js/background/components/tds.js @@ -1,6 +1,7 @@ import ResourceLoader from './resource-loader.js'; import constants from '../../../data/constants'; import { generateRetentionMetrics } from './abn-experiments.js'; +import { generateBreakageMetrics } from '../metrics.js'; /** * @typedef {import('../settings.js')} Settings @@ -41,7 +42,9 @@ export default class TDSStorage { }, { settings }, ); - this.remoteConfig.onUpdate(this.checkShouldOverrideTDS.bind(this)); + this.remoteConfig.onUpdate(() => { + setTimeout(this.checkShouldOverrideTDS.bind(this), 1); + }); } ready() { @@ -79,12 +82,7 @@ export default class TDSStorage { } this.abnMetrics.markExperimentEnrolled(CONTENT_BLOCKING, subFeatureName, [ ...generateRetentionMetrics(), - { - metric: 'brokenSiteReport', - conversionWindowStart: 0, - conversionWindowEnd: 14, - value: 1, - }, + ...generateBreakageMetrics(), ]); } else if (this.settings.getSetting(TDS_OVERRIDE_SETTINGS_KEY)) { // User removed from experiment/rollout, reset TDS override and fetch default list diff --git a/shared/js/background/components/toggle-reports.js b/shared/js/background/components/toggle-reports.js index 4586517ff..908a49810 100644 --- a/shared/js/background/components/toggle-reports.js +++ b/shared/js/background/components/toggle-reports.js @@ -1,6 +1,6 @@ import browser from 'webextension-polyfill'; import { registerMessageHandler } from '../message-handlers'; -import { postPopupMessage } from '../popupMessaging'; +import { postPopupMessage } from '../popup-messaging'; import settings from '../settings'; import { getFeatureSettings, reloadCurrentTab, resolveAfterDelay } from '../utils'; import { getDisclosureDetails, sendBreakageReportForCurrentTab } from '../broken-site-report'; diff --git a/shared/js/background/events.js b/shared/js/background/events.js index f8656d9d8..5d46ab091 100644 --- a/shared/js/background/events.js +++ b/shared/js/background/events.js @@ -5,13 +5,12 @@ * if we do too much before adding it */ import browser from 'webextension-polyfill'; -import messageHandlers from './message-handlers'; import { updateActionIcon } from './events/privacy-icon-indicator'; import httpsStorage from './storage/https'; import ATB from './atb'; import { clearExpiredBrokenSiteReportTimes } from './broken-site-report'; import { sendPageloadsWithAdAttributionPixelAndResetCount } from './classes/ad-click-attribution-policy'; -import { popupConnectionOpened, postPopupMessage } from './popupMessaging'; +import { postPopupMessage } from './popup-messaging'; const utils = require('./utils'); const experiment = require('./experiments'); const settings = require('./settings'); @@ -294,65 +293,6 @@ if (browser.runtime.onPerformanceWarning) { }); } -/** - * MESSAGES - */ -// Handle any messages that come from content/UI scripts -browser.runtime.onMessage.addListener((req, sender) => { - if (sender.id !== browserWrapper.getExtensionId()) return; - - // TODO clean up message passing - const legacyMessageTypes = [ - 'addUserData', - 'getUserData', - 'removeUserData', - 'getEmailProtectionCapabilities', - 'getAddresses', - 'refreshAlias', - 'debuggerMessage', - ]; - for (const legacyMessageType of legacyMessageTypes) { - if (legacyMessageType in req) { - req.messageType = legacyMessageType; - req.options = req[legacyMessageType]; - } - } - - if (req.registeredTempAutofillContentScript) { - req.messageType = 'registeredContentScript'; - } - - if (req.messageType && req.messageType in messageHandlers) { - return Promise.resolve(messageHandlers[req.messageType](req.options, sender, req)); - } - - // Count refreshes per page - if (req.pageReloaded && sender.tab !== undefined) { - const tab = tabManager.get({ tabId: sender.tab.id }); - if (tab) { - tab.userRefreshCount += 1; - } - return; - } - - // TODO clean up legacy onboarding messaging - if (browserName === 'chrome') { - if (req === 'healthCheckRequest' || req === 'rescheduleCounterMessagingRequest') { - return; - } - } - - console.error('Unrecognized message to background:', req, sender); - return false; -}); - -// Handle popup UI (aka privacy dashboard) messaging. -browser.runtime.onConnect.addListener((port) => { - if (port.name === 'privacy-dashboard') { - popupConnectionOpened(port, messageHandlers); - } -}); - /* * Referrer Trimming */ diff --git a/shared/js/background/message-handlers.js b/shared/js/background/message-handlers.js index 25accfbbe..c4eb272f9 100644 --- a/shared/js/background/message-handlers.js +++ b/shared/js/background/message-handlers.js @@ -8,7 +8,7 @@ import { ensureClickToLoadRuleActionDisabled } from './dnr-click-to-load'; import tdsStorage from './storage/tds'; import { getArgumentsObject } from './helpers/arguments-object'; import { isFireButtonEnabled } from './components/fire-button'; -import { postPopupMessage } from './popupMessaging'; +import { postPopupMessage } from './popup-messaging'; import ToggleReports from './components/toggle-reports'; const utils = require('./utils'); const settings = require('./settings'); diff --git a/shared/js/background/metrics.js b/shared/js/background/metrics.js new file mode 100644 index 000000000..37edd75c8 --- /dev/null +++ b/shared/js/background/metrics.js @@ -0,0 +1,188 @@ +import browser from 'webextension-polyfill'; +import { MessageReceivedEvent } from './components/message-router'; + +/** + * @typedef {import('./components/abn-experiments').default} AbnExperimentMetrics + * @typedef {import('./components/message-router').default} MessageRouter + */ +export class AppUseMetric { + /** + * Metric that fires once on creation. + * @param {{ + * abnMetrics: AbnExperimentMetrics + * }} opts + */ + constructor({ abnMetrics }) { + // trigger on construction: happens whenever the service worker is spun up, which should correlate with browser activity. + // Note: we don't care about overtriggering here, as the experiment framework will handle deduplication. + // This metric will only correlate with when the browser is open, and not necessarily if it is being 'used'. + abnMetrics.remoteConfig.ready.then(() => abnMetrics.onMetricTriggered('app_use')); + } +} + +export class SearchMetric { + /** + * Metric that fires whenever a new search is made + * @param {{ + * abnMetrics: AbnExperimentMetrics + * }} opts + */ + constructor({ abnMetrics }) { + browser.webRequest.onCompleted.addListener( + async (details) => { + const params = new URL(details.url).searchParams; + if (params.has('q') && (params.get('q')?.length || 0) > 0) { + await abnMetrics.remoteConfig.ready; + abnMetrics.onMetricTriggered('search'); + } + }, + { + urls: ['https://*.duckduckgo.com/*'], + types: ['main_frame'], + }, + ); + } +} + +export class DashboardUseMetric { + messageToMetricMap = { + getPrivacyDashboardData: 'privacyDashboardOpen', + setLists: 'protectionToggle', + getBreakageFormOptions: 'breakageFormOpen', + doBurn: 'fireButton', + submitBrokenSiteReport: 'brokenSiteReport', + sendToggleReport: 'toggleSiteReport', + }; + + /** + * @param {{ + * abnMetrics: AbnExperimentMetrics; + * messaging: MessageRouter + * }} opts + */ + constructor({ abnMetrics, messaging }) { + messaging.addEventListener('messageReceived', (ev) => { + if (ev instanceof MessageReceivedEvent) { + if (this.messageToMetricMap[ev.messageType]) { + abnMetrics.onMetricTriggered(this.messageToMetricMap[ev.messageType]); + } + } + }); + } +} + +/** + * Metric to track repeated refreshes from a single tab. + * Listens for the `tabRefresh` event from the TabTracker, then counts the number of refreshes + * over given intervals. Note, a refresh here is defined as a webNavigation event to the same URL. + * Yields metrics for the following: + * - 2 refreshes within 12 seconds: '2xRefresh' + * - 3 refreshes within 20 seconds: '3xRefresh' + */ +export class RefreshMetric { + /** @type {Map} */ + tabRefreshCounter = new Map(); + DEBOUNCE_MS = 500; + + /** + * @param {{ + * abnMetrics: AbnExperimentMetrics, + * tabTracking: import('./components/tab-tracking').default, + * }} opts + */ + constructor({ abnMetrics, tabTracking }) { + tabTracking.addEventListener('tabRefresh', (ev) => { + if (ev instanceof CustomEvent) { + const { tabId, timeStamp } = ev.detail; + if (!this.tabRefreshCounter.has(tabId)) { + this.tabRefreshCounter.set(tabId, []); + } + const refreshCounter = this.tabRefreshCounter.get(tabId) || []; + if (refreshCounter.length > 0 && refreshCounter[refreshCounter.length - 1] > timeStamp - this.DEBOUNCE_MS) { + // last refresh less than DEBOUNCE_MS ago, ignore + return; + } + refreshCounter.push(timeStamp); + + // reload-twice-within-12-seconds + const tMinus12s = timeStamp - 12000; + if (refreshCounter.filter((t) => t > tMinus12s).length === 2) { + abnMetrics.onMetricTriggered('2xRefresh'); + } + // reload-three-times-within-20-seconds + const tMinus20s = timeStamp - 20000; + if (refreshCounter.filter((t) => t > tMinus20s).length === 3) { + abnMetrics.onMetricTriggered('3xRefresh'); + } + + // clean up old refreshes + while (refreshCounter.length > 0 && refreshCounter[0] < tMinus20s) { + refreshCounter.shift(); + } + if (refreshCounter.length === 0) { + this.tabRefreshCounter.delete(tabId); + } + } + }); + } +} + +/** + * Default set of metrics we want to send for breakage experiments, in compact + * tuple format (so it's easy to modify). + * @type {[string, number, number, number][]} + */ +const breakageMetricSpec = [ + ['2xRefresh', 0, 2, 1], + ['2xRefresh', 0, 3, 1], + ['2xRefresh', 0, 4, 1], + ['2xRefresh', 0, 5, 1], + ['2xRefresh', 0, 2, 5], + ['2xRefresh', 0, 3, 5], + ['2xRefresh', 0, 4, 5], + ['2xRefresh', 0, 5, 5], + ['2xRefresh', 0, 2, 10], + ['2xRefresh', 0, 3, 10], + ['2xRefresh', 0, 4, 10], + ['2xRefresh', 0, 5, 10], + ['3xRefresh', 0, 2, 1], + ['3xRefresh', 0, 3, 1], + ['3xRefresh', 0, 4, 1], + ['3xRefresh', 0, 5, 1], + ['3xRefresh', 0, 2, 5], + ['3xRefresh', 0, 3, 5], + ['3xRefresh', 0, 4, 5], + ['3xRefresh', 0, 5, 5], + ['3xRefresh', 0, 2, 10], + ['3xRefresh', 0, 3, 10], + ['3xRefresh', 0, 4, 10], + ['3xRefresh', 0, 5, 10], + ['brokenSiteReport', 0, 5, 1], + ['brokenSiteReport', 0, 5, 2], + ['brokenSiteReport', 0, 5, 3], + ['toggleSiteReport', 0, 5, 1], + ['toggleSiteReport', 0, 5, 2], + ['toggleSiteReport', 0, 5, 3], + ['breakageFormOpen', 0, 5, 1], + ['breakageFormOpen', 0, 5, 2], + ['breakageFormOpen', 0, 5, 3], + ['privacyDashboardOpen', 0, 5, 1], + ['privacyDashboardOpen', 0, 5, 5], + ['privacyDashboardOpen', 0, 5, 10], + ['protectionToggle', 0, 5, 1], + ['protectionToggle', 0, 5, 5], + ['protectionToggle', 0, 5, 10], +]; + +/** + * Get default set of metrics for a breakage experiment. + * @returns {import('./components/abn-experiments').ExperimentMetric[]} + */ +export function generateBreakageMetrics() { + return breakageMetricSpec.map(([metric, conversionWindowStart, conversionWindowEnd, value]) => ({ + metric, + conversionWindowStart, + conversionWindowEnd, + value, + })); +} diff --git a/shared/js/background/popup-messaging.js b/shared/js/background/popup-messaging.js new file mode 100644 index 000000000..173cc206f --- /dev/null +++ b/shared/js/background/popup-messaging.js @@ -0,0 +1,29 @@ +/** + * @typedef {import('webextension-polyfill').Runtime.Port} Port + * @typedef {import('@duckduckgo/privacy-dashboard/schema/__generated__/schema.types').IncomingExtensionMessage} OutgoingPopupMessage + */ + +// Messaging connection with the popup UI (when active). +/** @type {Port?} */ +let activePort = null; + +export function getActivePort() { + return activePort; +} + +/** + * + * @param {Port?} port + */ +export function setActivePort(port) { + activePort = port; +} + +/** + * Post a message to the popup UI, if it's open. + * + * @param {OutgoingPopupMessage} message + */ +export function postPopupMessage(message) { + activePort?.postMessage(message); +} diff --git a/shared/js/background/popupMessaging.js b/shared/js/background/popupMessaging.js deleted file mode 100644 index 08b863a7e..000000000 --- a/shared/js/background/popupMessaging.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @typedef {import('webextension-polyfill').Runtime.Port} Port - * @typedef {import('@duckduckgo/privacy-dashboard/schema/__generated__/schema.types').IncomingExtensionMessage} OutgoingPopupMessage - */ - -// Messaging connection with the popup UI (when active). -/** @type {Port?} */ -let activePort = null; - -/** - * Set up the messaging connection with the popup UI. - * - * Note: If the ServiceWorker dies while there is an open connection, the popup - * will take care of re-opening the connection. - * - * @param {Port} port - * @param {Record< - * string, - * (options: any, sender: any, message: any) => any - * >} messageHandlers - */ -export function popupConnectionOpened(port, messageHandlers) { - activePort = port; - port.onDisconnect.addListener(() => { - if (activePort === port) { - activePort = null; - } - }); - - port.onMessage.addListener(async (message) => { - const messageType = message?.messageType; - - if (!messageType || !(messageType in messageHandlers)) { - console.error('Unrecognized message (privacy-dashboard -> background):', message); - return; - } - - const response = await messageHandlers[messageType](message?.options, port, message); - if (typeof message?.id === 'number') { - port.postMessage({ - id: message.id, - messageType: 'response', - options: response, - }); - } - }); -} - -/** - * Post a message to the popup UI, if it's open. - * - * @param {OutgoingPopupMessage} message - */ -export function postPopupMessage(message) { - activePort?.postMessage(message); -} diff --git a/unit-test/background/metrics.js b/unit-test/background/metrics.js new file mode 100644 index 000000000..39d97f41a --- /dev/null +++ b/unit-test/background/metrics.js @@ -0,0 +1,84 @@ +import { RefreshMetric } from '../../shared/js/background/metrics'; + +class MockAbnExperimentMetrics { + onMetricTriggered() {} +} + +class MockTabTracking extends EventTarget { + mockRefreshEvent(tabId, timeStamp = Date.now()) { + this.dispatchEvent( + new CustomEvent('tabRefresh', { + detail: { + tabId, + timeStamp, + }, + }), + ); + } +} + +describe('RefreshMetric', () => { + let abnMetrics; + let tabTracking; + /** @type {RefreshMetric} */ + let metric; + /** @type {jasmine.Spy} */ + let metricsSpy; + + beforeEach(() => { + abnMetrics = new MockAbnExperimentMetrics(); + tabTracking = new MockTabTracking(); + metricsSpy = spyOn(abnMetrics, 'onMetricTriggered'); + metric = new RefreshMetric({ + abnMetrics, + tabTracking, + }); + }); + + it('triggers 2xRefresh metric', () => { + tabTracking.mockRefreshEvent(1, Date.now() - 5000); + tabTracking.mockRefreshEvent(1, Date.now() - 1000); + expect(metricsSpy).toHaveBeenCalledOnceWith('2xRefresh'); + }); + + it('triggers 3xRefresh metric', () => { + tabTracking.mockRefreshEvent(1, Date.now() - 5000); + tabTracking.mockRefreshEvent(1, Date.now() - 3000); + tabTracking.mockRefreshEvent(1, Date.now() - 1000); + expect(metricsSpy).toHaveBeenCalledWith('2xRefresh'); + expect(metricsSpy).toHaveBeenCalledWith('3xRefresh'); + }); + + it('debounces spammed refreshes', () => { + const t = Date.now() - 1000; + tabTracking.mockRefreshEvent(1, t); + tabTracking.mockRefreshEvent(1, t + 1); + tabTracking.mockRefreshEvent(1, t + 2); + tabTracking.mockRefreshEvent(1, t + 3); + tabTracking.mockRefreshEvent(1, t + 4); + expect(metricsSpy).toHaveBeenCalledTimes(0); + tabTracking.mockRefreshEvent(1, t + metric.DEBOUNCE_MS + 4); + tabTracking.mockRefreshEvent(1, t + metric.DEBOUNCE_MS + 5); + expect(metricsSpy).toHaveBeenCalledOnceWith('2xRefresh'); + }); + + it('triggers 2xRefresh if the events happened within 12s, but not otherwise', () => { + const t = Date.now() - 30000; + tabTracking.mockRefreshEvent(1, t); + tabTracking.mockRefreshEvent(1, t + 12100); + expect(metricsSpy).toHaveBeenCalledTimes(0); + tabTracking.mockRefreshEvent(1, t + 21000); + expect(metricsSpy).toHaveBeenCalledOnceWith('2xRefresh'); + }); + + it('triggers 3xRefresh if the events happend within 20s, but not otherwise', () => { + const t = 30000; + tabTracking.mockRefreshEvent(1, t); + tabTracking.mockRefreshEvent(1, t + 1000); + expect(metricsSpy).toHaveBeenCalledOnceWith('2xRefresh'); + tabTracking.mockRefreshEvent(1, t + 20100); + expect(metricsSpy.calls.all().map((c) => c.args[0])).toEqual(['2xRefresh']); + tabTracking.mockRefreshEvent(1, t + 20900); + expect(metricsSpy.calls.all().map((c) => c.args[0])).toEqual(['2xRefresh', '2xRefresh', '3xRefresh']); + }); +});