summaryrefslogtreecommitdiffstats
path: root/toolkit/components/contentrelevancy
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/contentrelevancy/ContentRelevancyManager.sys.mjs326
-rw-r--r--toolkit/components/contentrelevancy/docs/index.md3
-rw-r--r--toolkit/components/contentrelevancy/moz.build27
-rw-r--r--toolkit/components/contentrelevancy/private/InputUtils.sys.mjs118
-rw-r--r--toolkit/components/contentrelevancy/tests/browser/browser.toml6
-rw-r--r--toolkit/components/contentrelevancy/tests/browser/browser_contentrelevancy_nimbus.js91
-rw-r--r--toolkit/components/contentrelevancy/tests/xpcshell/head.js6
-rw-r--r--toolkit/components/contentrelevancy/tests/xpcshell/test_ContentRelevancyManager.js149
-rw-r--r--toolkit/components/contentrelevancy/tests/xpcshell/test_InputUtils.js98
-rw-r--r--toolkit/components/contentrelevancy/tests/xpcshell/xpcshell.toml9
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