diff options
Diffstat (limited to '')
10 files changed, 833 insertions, 0 deletions
diff --git a/toolkit/components/contentrelevancy/ContentRelevancyManager.sys.mjs b/toolkit/components/contentrelevancy/ContentRelevancyManager.sys.mjs new file mode 100644 index 0000000000..ea3f2a78a2 --- /dev/null +++ b/toolkit/components/contentrelevancy/ContentRelevancyManager.sys.mjs @@ -0,0 +1,326 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + getFrecentRecentCombinedUrls: + "resource://gre/modules/contentrelevancy/private/InputUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + RelevancyStore: "resource://gre/modules/RustRelevancy.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "timerManager", + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager" +); + +// Constants used by `nsIUpdateTimerManager` for a cross-session timer. +const TIMER_ID = "content-relevancy-timer"; +const PREF_TIMER_LAST_UPDATE = `app.update.lastUpdateTime.${TIMER_ID}`; +const PREF_TIMER_INTERVAL = "toolkit.contentRelevancy.timerInterval"; +// Set the timer interval to 1 day for validation. +const DEFAULT_TIMER_INTERVAL_SECONDS = 1 * 24 * 60 * 60; + +// Default maximum input URLs to fetch from Places. +const DEFAULT_MAX_URLS = 100; +// Default minimal input URLs for clasification. +const DEFAULT_MIN_URLS = 0; + +// File name of the relevancy database +const RELEVANCY_STORE_FILENAME = "content-relevancy.sqlite"; + +// Nimbus variables +const NIMBUS_VARIABLE_ENABLED = "enabled"; +const NIMBUS_VARIABLE_MAX_INPUT_URLS = "maxInputUrls"; +const NIMBUS_VARIABLE_MIN_INPUT_URLS = "minInputUrls"; +const NIMBUS_VARIABLE_TIMER_INTERVAL = "timerInterval"; + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + return console.createInstance({ + prefix: "ContentRelevancyManager", + maxLogLevel: Services.prefs.getBoolPref( + "toolkit.contentRelevancy.log", + false + ) + ? "Debug" + : "Error", + }); +}); + +class RelevancyManager { + get initialized() { + return this.#initialized; + } + + /** + * Init the manager. An update timer is registered if the feature is enabled. + * The pref observer is always set so we can toggle the feature without restarting + * the browser. + * + * Note that this should be called once only. `#enable` and `#disable` can be + * used to toggle the feature once the manager is initialized. + */ + async init() { + if (this.initialized) { + return; + } + + lazy.log.info("Initializing the manager"); + + if (this.shouldEnable) { + await this.#enable(); + } + + this._nimbusUpdateCallback = this.#onNimbusUpdate.bind(this); + // This will handle both Nimbus updates and pref changes. + lazy.NimbusFeatures.contentRelevancy.onUpdate(this._nimbusUpdateCallback); + this.#initialized = true; + } + + uninit() { + if (!this.initialized) { + return; + } + + lazy.log.info("Uninitializing the manager"); + + lazy.NimbusFeatures.contentRelevancy.offUpdate(this._nimbusUpdateCallback); + this.#disable(); + + this.#initialized = false; + } + + /** + * Determine whether the feature should be enabled based on prefs and Nimbus. + */ + get shouldEnable() { + return ( + lazy.NimbusFeatures.contentRelevancy.getVariable( + NIMBUS_VARIABLE_ENABLED + ) ?? false + ); + } + + #startUpTimer() { + // Log the last timer tick for debugging. + const lastTick = Services.prefs.getIntPref(PREF_TIMER_LAST_UPDATE, 0); + if (lastTick) { + lazy.log.debug( + `Last timer tick: ${lastTick}s (${ + Math.round(Date.now() / 1000) - lastTick + })s ago` + ); + } else { + lazy.log.debug("Last timer tick: none"); + } + + const interval = + lazy.NimbusFeatures.contentRelevancy.getVariable( + NIMBUS_VARIABLE_TIMER_INTERVAL + ) ?? + Services.prefs.getIntPref( + PREF_TIMER_INTERVAL, + DEFAULT_TIMER_INTERVAL_SECONDS + ); + lazy.timerManager.registerTimer( + TIMER_ID, + this, + interval, + interval != 0 // Do not skip the first timer tick for a zero interval for testing + ); + } + + get #storePath() { + return PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + RELEVANCY_STORE_FILENAME + ); + } + + async #enable() { + if (!this.#_store) { + // Init the relevancy store. + const path = this.#storePath; + lazy.log.info(`Initializing RelevancyStore: ${path}`); + + try { + this.#_store = await lazy.RelevancyStore.init(path); + } catch (error) { + lazy.log.error(`Error initializing RelevancyStore: ${error}`); + return; + } + } + + this.#startUpTimer(); + } + + /** + * The reciprocal of `#enable()`, ensure this is safe to call when you add + * new disabling code here. It should be so even if `#enable()` hasn't been + * called. + */ + #disable() { + this.#_store = null; + lazy.timerManager.unregisterTimer(TIMER_ID); + } + + async #toggleFeature() { + if (this.shouldEnable) { + await this.#enable(); + } else { + this.#disable(); + } + } + + /** + * nsITimerCallback + */ + notify() { + lazy.log.info("Background job timer fired"); + this.#doClassification(); + } + + get isInProgress() { + return this.#isInProgress; + } + + /** + * Perform classification based on browsing history. + * + * It will fetch up to `DEFAULT_MAX_URLS` (or the corresponding Nimbus value) + * URLs from top frecent URLs and use most recent URLs as a fallback if the + * former is insufficient. The returned URLs might be fewer than requested. + * + * The classification will not be performed if the total number of input URLs + * is less than `DEFAULT_MIN_URLS` (or the corresponding Nimbus value). + */ + async #doClassification() { + if (this.isInProgress) { + lazy.log.info( + "Another classification is in progress, aborting interest classification" + ); + return; + } + + // Set a flag indicating this classification. Ensure it's cleared upon early + // exit points & success. + this.#isInProgress = true; + + try { + lazy.log.info("Fetching input data for interest classification"); + + const maxUrls = + lazy.NimbusFeatures.contentRelevancy.getVariable( + NIMBUS_VARIABLE_MAX_INPUT_URLS + ) ?? DEFAULT_MAX_URLS; + const minUrls = + lazy.NimbusFeatures.contentRelevancy.getVariable( + NIMBUS_VARIABLE_MIN_INPUT_URLS + ) ?? DEFAULT_MIN_URLS; + const urls = await lazy.getFrecentRecentCombinedUrls(maxUrls); + if (urls.length < minUrls) { + lazy.log.info("Aborting interest classification: insufficient input"); + return; + } + + lazy.log.info("Starting interest classification"); + await this.#doClassificationHelper(urls); + } catch (error) { + if (error instanceof StoreNotAvailableError) { + lazy.log.error("#store became null, aborting interest classification"); + } else { + lazy.log.error("Classification error: " + (error.reason ?? error)); + } + } finally { + this.#isInProgress = false; + } + + lazy.log.info("Finished interest classification"); + } + + /** + * Classification helper. Use the getter `this.#store` rather than `#_store` + * to access the store so that when it becomes null, a `StoreNotAvailableError` + * will be raised. Likewise, other store related errors should be propagated + * to the caller if you want to perform custom error handling in this helper. + * + * @param {Array} urls + * An array of URLs. + * @throws {StoreNotAvailableError} + * Thrown when the store became unavailable (i.e. set to null elsewhere). + * @throws {RelevancyAPIError} + * Thrown for other API errors on the store. + */ + async #doClassificationHelper(urls) { + // The following logs are unnecessary, only used to suppress the linting error. + // TODO(nanj): delete me once the following TODO is done. + if (!this.#store) { + lazy.log.error("#store became null, aborting interest classification"); + } + lazy.log.info("Classification input: " + urls); + + // TODO(nanj): uncomment the following once `ingest()` is implemented. + // await this.#store.ingest(urls); + } + + /** + * Exposed for testing. + */ + async _test_doClassification(urls) { + await this.#doClassificationHelper(urls); + } + + /** + * Internal getter for `#_store` used by for classification. It will throw + * a `StoreNotAvailableError` is the store is not ready. + */ + get #store() { + if (!this._isStoreReady) { + throw new StoreNotAvailableError("Store is not available"); + } + + return this.#_store; + } + + /** + * Whether or not the store is ready (i.e. not null). + */ + get _isStoreReady() { + return !!this.#_store; + } + + /** + * Nimbus update listener. + */ + #onNimbusUpdate(_event, _reason) { + this.#toggleFeature(); + } + + // The `RustRelevancy` store. + #_store; + + // Whether or not the module is initialized. + #initialized = false; + + // Whether or not there is an in-progress classification. Used to prevent + // duplicate classification tasks. + #isInProgress = false; +} + +/** + * Error raised when attempting to access a null store. + */ +class StoreNotAvailableError extends Error { + constructor(message, ...params) { + super(message, ...params); + this.name = "StoreNotAvailableError"; + } +} + +export var ContentRelevancyManager = new RelevancyManager(); diff --git a/toolkit/components/contentrelevancy/docs/index.md b/toolkit/components/contentrelevancy/docs/index.md new file mode 100644 index 0000000000..bd377d68dc --- /dev/null +++ b/toolkit/components/contentrelevancy/docs/index.md @@ -0,0 +1,3 @@ +# Content Relevancy + +This is the home for the project: Interest-based Content Relevance Ranking & Personalization for Firefox, a client-based privacy preserving approach to enhancing content experience of Firefox. diff --git a/toolkit/components/contentrelevancy/moz.build b/toolkit/components/contentrelevancy/moz.build new file mode 100644 index 0000000000..551af69924 --- /dev/null +++ b/toolkit/components/contentrelevancy/moz.build @@ -0,0 +1,27 @@ +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Application Services", "Relevancy") + +EXTRA_JS_MODULES += [ + "ContentRelevancyManager.sys.mjs", +] + +EXTRA_JS_MODULES["contentrelevancy/private"] += [ + "private/InputUtils.sys.mjs", +] + +XPCSHELL_TESTS_MANIFESTS += [ + "tests/xpcshell/xpcshell.toml", +] + +BROWSER_CHROME_MANIFESTS += [ + "tests/browser/browser.toml", +] + +SPHINX_TREES["/toolkit/components/contentrelevancy"] = "docs" + +with Files("docs/**"): + SCHEDULES.exclusive = ["docs"] diff --git a/toolkit/components/contentrelevancy/private/InputUtils.sys.mjs b/toolkit/components/contentrelevancy/private/InputUtils.sys.mjs new file mode 100644 index 0000000000..eccc5a5768 --- /dev/null +++ b/toolkit/components/contentrelevancy/private/InputUtils.sys.mjs @@ -0,0 +1,118 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", +}); + +/** + * Get the URLs with the top frecency scores. + * + * Note: + * - Blocked URLs are excluded + * - Allow multiple URLs from the same domain (www vs non-www urls) + * + * @param {number} maxUrls + * The maximum number of URLs to fetch. + * @param {number} frecencyThreshold + * The minimal frecency score of the URL. Will use the default set by + * the upstream module if unspecified. For reference, "100" means one + * visit in the past 3 days. see more details at: + * `/browser/components/urlbar/docs/ranking.rst` + * @returns {Array} + * An array of URLs. Note that the actual number could be less than `maxUrls`. + */ +export async function getTopFrecentUrls( + maxUrls, + frecencyThreshold /* for test */ +) { + const options = { + ignoreBlocked: true, + onePerDomain: false, + includeFavicon: false, + topsiteFrecency: frecencyThreshold, + numItems: maxUrls, + }; + const records = await lazy.NewTabUtils.activityStreamLinks.getTopSites( + options + ); + + return records.map(site => site.url); +} + +/** + * Get the URLs of the most recent browsing history. + * + * Note: + * - Blocked URLs are excluded + * - Recent bookmarks are excluded + * - Recent "Save-to-Pocket" URLs are excluded + * - It would only return URLs if the page meta data is present. We can relax + * this in the future. + * - Multiple URLs may be returned for the same domain + * + * @param {number} maxUrls + * The maximum number of URLs to fetch. + * @returns {Array} + * An array of URLs. Note that the actual number could be less than `maxUrls`. + */ +export async function getMostRecentUrls(maxUrls) { + const options = { + ignoreBlocked: true, + excludeBookmarks: true, + excludeHistory: false, + excludePocket: true, + withFavicons: false, + numItems: maxUrls, + }; + const records = await lazy.NewTabUtils.activityStreamLinks.getHighlights( + options + ); + + return records.map(site => site.url); +} + +/** + * Get the URLs as a combination of the top frecent and the most recent + * browsing history. + * + * It will fetch `maxUrls` URLs from top frecent URLs and use most recent URLs + * as a fallback if the former is insufficient. Duplicates will be removed + * As a result, the returned URLs might be fewer than requested. + * + * @param {number} maxUrls + * The maximum number of URLs to fetch. + * @returns {Array} + * An array of URLs. + */ +export async function getFrecentRecentCombinedUrls(maxUrls) { + let urls = await getTopFrecentUrls(maxUrls); + if (urls.length < maxUrls) { + const n = Math.round((maxUrls - urls.length) * 1.2); // Over-fetch for deduping + const recentUrls = await getMostRecentUrls(n); + urls = dedupUrls(urls, recentUrls).slice(0, maxUrls); + } + + return urls; +} + +/** + * A helper to deduplicate items from any number of grouped URLs. + * + * Note: + * - Currently, all the elements (URLs) of the input arrays are treated as keys. + * - It doesn't assume the uniqueness within the group, therefore, in-group + * duplicates will be deduped as well. + * + * @param {Array} groups + * Contains an arbitrary number of arrays of URLs. + * @returns {Array} + * An array of unique URLs from the input groups. + */ +function dedupUrls(...groups) { + const uniques = new Set(groups.flat()); + return [...uniques]; +} diff --git a/toolkit/components/contentrelevancy/tests/browser/browser.toml b/toolkit/components/contentrelevancy/tests/browser/browser.toml new file mode 100644 index 0000000000..ec1d3a3e66 --- /dev/null +++ b/toolkit/components/contentrelevancy/tests/browser/browser.toml @@ -0,0 +1,6 @@ +[DEFAULT] +prefs = [ + "toolkit.contentRelevancy.enabled=false", +] + +["browser_contentrelevancy_nimbus.js"] diff --git a/toolkit/components/contentrelevancy/tests/browser/browser_contentrelevancy_nimbus.js b/toolkit/components/contentrelevancy/tests/browser/browser_contentrelevancy_nimbus.js new file mode 100644 index 0000000000..47d54c2a87 --- /dev/null +++ b/toolkit/components/contentrelevancy/tests/browser/browser_contentrelevancy_nimbus.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentAPI } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); +const { ContentRelevancyManager } = ChromeUtils.importESModule( + "resource://gre/modules/ContentRelevancyManager.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +let gSandbox; + +add_setup(() => { + gSandbox = sinon.createSandbox(); + + registerCleanupFunction(() => { + gSandbox.restore(); + }); +}); + +/** + * Test Nimbus integration - enable. + */ +add_task(async function test_NimbusIntegration_enable() { + gSandbox.spy(ContentRelevancyManager, "notify"); + + await ExperimentAPI.ready(); + const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "contentRelevancy", + value: { + enabled: true, + minInputUrls: 1, + maxInputUrls: 3, + // Set the timer interval to 0 will trigger the timer right away. + timerInterval: 0, + }, + }); + + await TestUtils.waitForCondition( + () => ContentRelevancyManager.shouldEnable, + "Should enable it via Nimbus" + ); + + await TestUtils.waitForCondition( + () => ContentRelevancyManager.notify.called, + "The timer callback should be called" + ); + + await doExperimentCleanup(); + gSandbox.restore(); +}); + +/** + * Test Nimbus integration - disable. + */ +add_task(async function test_NimbusIntegration_disable() { + gSandbox.spy(ContentRelevancyManager, "notify"); + + await ExperimentAPI.ready(); + const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "contentRelevancy", + value: { + enabled: false, + minInputUrls: 1, + maxInputUrls: 3, + // Set the timer interval to 0 will trigger the timer right away. + timerInterval: 0, + }, + }); + + await TestUtils.waitForCondition( + () => !ContentRelevancyManager.shouldEnable, + "Should disable it via Nimbus" + ); + + await TestUtils.waitForCondition( + () => ContentRelevancyManager.notify.notCalled, + "The timer callback should not be called" + ); + + await doExperimentCleanup(); + gSandbox.restore(); +}); diff --git a/toolkit/components/contentrelevancy/tests/xpcshell/head.js b/toolkit/components/contentrelevancy/tests/xpcshell/head.js new file mode 100644 index 0000000000..e8c31f589c --- /dev/null +++ b/toolkit/components/contentrelevancy/tests/xpcshell/head.js @@ -0,0 +1,6 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This requires the profile directory for Places and the content relevancy +// component needs a profile directory for storage. +do_get_profile(); diff --git a/toolkit/components/contentrelevancy/tests/xpcshell/test_ContentRelevancyManager.js b/toolkit/components/contentrelevancy/tests/xpcshell/test_ContentRelevancyManager.js new file mode 100644 index 0000000000..633f9fc49b --- /dev/null +++ b/toolkit/components/contentrelevancy/tests/xpcshell/test_ContentRelevancyManager.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ContentRelevancyManager: + "resource://gre/modules/ContentRelevancyManager.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const PREF_CONTENT_RELEVANCY_ENABLED = "toolkit.contentRelevancy.enabled"; +const PREF_TIMER_INTERVAL = "toolkit.contentRelevancy.timerInterval"; + +// These consts are copied from the update timer manager test. See +// `initUpdateTimerManager()`. +const PREF_APP_UPDATE_TIMERMINIMUMDELAY = "app.update.timerMinimumDelay"; +const PREF_APP_UPDATE_TIMERFIRSTINTERVAL = "app.update.timerFirstInterval"; +const MAIN_TIMER_INTERVAL = 1000; // milliseconds +const CATEGORY_UPDATE_TIMER = "update-timer"; + +let gSandbox; + +add_setup(async () => { + gSandbox = sinon.createSandbox(); + initUpdateTimerManager(); + Services.prefs.setBoolPref(PREF_CONTENT_RELEVANCY_ENABLED, true); + await ContentRelevancyManager.init(); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_CONTENT_RELEVANCY_ENABLED); + gSandbox.restore(); + }); +}); + +add_task(async function test_init() { + Assert.ok(ContentRelevancyManager.initialized, "Init should succeed"); +}); + +add_task(async function test_uninit() { + ContentRelevancyManager.uninit(); + + Assert.ok(!ContentRelevancyManager.initialized, "Uninit should succeed"); +}); + +add_task(async function test_timer() { + // Set the timer interval to 0 will trigger the timer right away. + Services.prefs.setIntPref(PREF_TIMER_INTERVAL, 0); + gSandbox.spy(ContentRelevancyManager, "notify"); + + await ContentRelevancyManager.init(); + + await TestUtils.waitForCondition( + () => ContentRelevancyManager.notify.called, + "The timer callback should be called" + ); + + Services.prefs.clearUserPref(PREF_TIMER_INTERVAL); + gSandbox.restore(); +}); + +add_task(async function test_feature_toggling() { + Services.prefs.setBoolPref(PREF_CONTENT_RELEVANCY_ENABLED, false); + // Set the timer interval to 0 will trigger the timer right away. + Services.prefs.setIntPref(PREF_TIMER_INTERVAL, 0); + gSandbox.spy(ContentRelevancyManager, "notify"); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1100)); + Assert.ok( + ContentRelevancyManager.notify.notCalled, + "Timer should not be registered if disabled" + ); + + // Toggle the pref again should re-enable the feature. + Services.prefs.setBoolPref(PREF_CONTENT_RELEVANCY_ENABLED, true); + await TestUtils.waitForTick(); + + await TestUtils.waitForCondition( + () => ContentRelevancyManager.notify.called, + "The timer callback should be called" + ); + + Services.prefs.clearUserPref(PREF_CONTENT_RELEVANCY_ENABLED); + Services.prefs.clearUserPref(PREF_TIMER_INTERVAL); + gSandbox.restore(); +}); + +add_task(async function test_call_disable_twice() { + Services.prefs.setBoolPref(PREF_CONTENT_RELEVANCY_ENABLED, false); + await TestUtils.waitForTick(); + + Services.prefs.setBoolPref(PREF_CONTENT_RELEVANCY_ENABLED, false); + await TestUtils.waitForTick(); + + Assert.ok(true, "`#disable` should be safe to call multiple times"); + + Services.prefs.clearUserPref(PREF_CONTENT_RELEVANCY_ENABLED); +}); + +add_task(async function test_doClassification() { + Services.prefs.setBoolPref(PREF_CONTENT_RELEVANCY_ENABLED, true); + await TestUtils.waitForCondition(() => ContentRelevancyManager._isStoreReady); + await ContentRelevancyManager._test_doClassification([]); + + // Disable it to reset the store. + Services.prefs.setBoolPref(PREF_CONTENT_RELEVANCY_ENABLED, false); + await TestUtils.waitForTick(); + + await Assert.rejects( + ContentRelevancyManager._test_doClassification([]), + /Store is not available/, + "Should throw with an unset store" + ); + + Services.prefs.clearUserPref(PREF_CONTENT_RELEVANCY_ENABLED); +}); + +/** + * Sets up the update timer manager for testing: makes it fire more often, + * removes all existing timers, and initializes it for testing. The body of this + * function is copied from: + * https://searchfox.org/mozilla-central/source/toolkit/components/timermanager/tests/unit/consumerNotifications.js + */ +function initUpdateTimerManager() { + // Set the timer to fire every second + Services.prefs.setIntPref( + PREF_APP_UPDATE_TIMERMINIMUMDELAY, + MAIN_TIMER_INTERVAL / 1000 + ); + Services.prefs.setIntPref( + PREF_APP_UPDATE_TIMERFIRSTINTERVAL, + MAIN_TIMER_INTERVAL + ); + + // Remove existing update timers to prevent them from being notified + for (let { data: entry } of Services.catMan.enumerateCategory( + CATEGORY_UPDATE_TIMER + )) { + Services.catMan.deleteCategoryEntry(CATEGORY_UPDATE_TIMER, entry, false); + } + + Cc["@mozilla.org/updates/timer-manager;1"] + .getService(Ci.nsIUpdateTimerManager) + .QueryInterface(Ci.nsIObserver) + .observe(null, "utm-test-init", ""); +} diff --git a/toolkit/components/contentrelevancy/tests/xpcshell/test_InputUtils.js b/toolkit/components/contentrelevancy/tests/xpcshell/test_InputUtils.js new file mode 100644 index 0000000000..2bb2b8e62e --- /dev/null +++ b/toolkit/components/contentrelevancy/tests/xpcshell/test_InputUtils.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + getFrecentRecentCombinedUrls: + "resource://gre/modules/contentrelevancy/private/InputUtils.sys.mjs", + getMostRecentUrls: + "resource://gre/modules/contentrelevancy/private/InputUtils.sys.mjs", + getTopFrecentUrls: + "resource://gre/modules/contentrelevancy/private/InputUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", +}); + +const FRECENCY_SCORE_FOR_ONE_VISIT = 100; +const TEST_VISITS = [ + "http://test-1.com/", + "http://test-2.com/", + "http://test-3.com/", + "http://test-4.com/", +]; + +add_task(async function test_GetTopFrecentUrls() { + await PlacesUtils.history.clear(); + let urls = new Set(await getTopFrecentUrls(3, FRECENCY_SCORE_FOR_ONE_VISIT)); + + Assert.strictEqual(urls.size, 0, "Should have no top frecent links."); + + await PlacesTestUtils.addVisits(TEST_VISITS); + urls = new Set(await getTopFrecentUrls(3, FRECENCY_SCORE_FOR_ONE_VISIT)); + + Assert.strictEqual(urls.size, 3, "Should fetch the expected links"); + urls.forEach(url => { + Assert.ok(TEST_VISITS.includes(url), "Should be a link of the test visits"); + }); +}); + +add_task(async function test_GetMostRecentUrls() { + await PlacesUtils.history.clear(); + let urls = new Set(await getMostRecentUrls(3)); + + Assert.strictEqual(urls.size, 0, "Should have no recent links."); + + // Add visits and page meta data. + await PlacesTestUtils.addVisits(TEST_VISITS); + for (let url of TEST_VISITS) { + await PlacesUtils.history.update({ + description: "desc", + previewImageURL: "https://image/", + url, + }); + } + + urls = new Set(await getMostRecentUrls(3)); + + Assert.strictEqual(urls.size, 3, "Should fetch the expected links"); + urls.forEach(url => { + Assert.ok(TEST_VISITS.includes(url), "Should be a link of the test visits"); + }); +}); + +add_task(async function test_GetFrecentRecentCombinedUrls() { + await PlacesUtils.history.clear(); + let urls = new Set(await getFrecentRecentCombinedUrls(3)); + + Assert.strictEqual(urls.size, 0, "Should have no links."); + + // Add visits and page meta data. + await PlacesTestUtils.addVisits(TEST_VISITS); + for (let url of TEST_VISITS) { + await PlacesUtils.history.update({ + description: "desc", + previewImageURL: "https://image/", + url, + }); + } + + urls = new Set(await getFrecentRecentCombinedUrls(3)); + + Assert.strictEqual(urls.size, 3, "Should fetch the expected links"); + urls.forEach(url => { + Assert.ok(TEST_VISITS.includes(url), "Should be a link of the test visits"); + }); + + // Try getting twice as many URLs as the total in Places. + urls = new Set(await getFrecentRecentCombinedUrls(TEST_VISITS.length * 2)); + + Assert.strictEqual( + urls.size, + TEST_VISITS.length, + "Should not include duplicates" + ); + urls.forEach(url => { + Assert.ok(TEST_VISITS.includes(url), "Should be a link of the test visits"); + }); +}); diff --git a/toolkit/components/contentrelevancy/tests/xpcshell/xpcshell.toml b/toolkit/components/contentrelevancy/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..70f6d45c2d --- /dev/null +++ b/toolkit/components/contentrelevancy/tests/xpcshell/xpcshell.toml @@ -0,0 +1,9 @@ +[DEFAULT] +head = "head.js" +firefox-appdir = "browser" + +["test_ContentRelevancyManager.js"] +skip-if = ["os == 'android'"] # bug 1886601 + +["test_InputUtils.js"] +skip-if = ["os == 'android'"] # bug 1886601 |