/* 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/. */ // Tests ingest in the Rust backend. "use strict"; ChromeUtils.defineESModuleGetters(this, { AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", InterruptKind: "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", SuggestIngestionMetrics: "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs", SuggestionProvider: "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs", }); // 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"; const REMOTE_SETTINGS_SUGGESTION = QuickSuggestTestUtils.ampRemoteSettings(); add_setup(async function () { initUpdateTimerManager(); await QuickSuggestTestUtils.ensureQuickSuggestInit({ remoteSettingsRecords: [ { type: "data", attachment: [REMOTE_SETTINGS_SUGGESTION], }, ], prefs: [ ["suggest.quicksuggest.all", true], ["suggest.quicksuggest.sponsored", true], ], }); }); // The backend should ingest when it's disabled and then re-enabled. add_task(async function disableEnable() { Assert.strictEqual( UrlbarPrefs.get("quicksuggest.rustEnabled"), true, "Sanity check: Rust pref is initially true" ); Assert.strictEqual( QuickSuggest.rustBackend.isEnabled, true, "Sanity check: Rust backend is initially enabled" ); let enabledTypes = QuickSuggest.rustBackend._test_enabledSuggestionTypes; Assert.greater( enabledTypes.length, 0, "This test expects some Rust suggestion types to be enabled" ); UrlbarPrefs.set("quicksuggest.rustEnabled", false); UrlbarPrefs.set("quicksuggest.rustEnabled", true); // `ingest()` must be stubbed only after re-enabling the backend since the // `SuggestStore` is recreated then. await withIngestStub(async ({ stub, rustBackend }) => { info("Awaiting ingest promise"); await rustBackend.ingestPromise; checkIngestCounts({ stub, expected: Object.fromEntries( enabledTypes.map(({ provider }) => [provider, 1]) ), }); }); }); // For a feature whose suggestion type provider has constraints, ingest should // happen when the constraints change. add_task(async function providerConstraintsChanged() { // We'll use the Dynamic feature since it has provider constraints. Make sure // it exists. let feature = QuickSuggest.getFeature("DynamicSuggestions"); Assert.ok( !!feature, "This test expects the DynamicSuggestions feature to exist" ); Assert.equal( feature.rustSuggestionType, "Dynamic", "This test expects Dynamic suggestions to exist" ); let providersFilter = [SuggestionProvider.DYNAMIC]; await withIngestStub(async ({ stub, rustBackend }) => { // Set the pref to a few non-empty string values. Each time, a dynamic // ingest should be triggered. for (let type of ["aaa", "bbb", "aaa,bbb"]) { UrlbarPrefs.set("quicksuggest.dynamicSuggestionTypes", type); info("Awaiting ingest promise after setting dynamicSuggestionTypes"); await rustBackend.ingestPromise; checkIngestCounts({ stub, providersFilter, expected: { [SuggestionProvider.DYNAMIC]: 1, }, }); } // Set the pref to an empty string. The feature should become disabled and // it shouldn't trigger ingest since no dynamic suggestions are enabled. UrlbarPrefs.set("quicksuggest.dynamicSuggestionTypes", ""); info( "Awaiting ingest promise after setting dynamicSuggestionTypes to empty string" ); await rustBackend.ingestPromise; Assert.ok( !feature.isEnabled, "Dynamic feature should be disabled after setting dynamicSuggestionTypes to empty string" ); checkIngestCounts({ stub, providersFilter, expected: {}, }); }); UrlbarPrefs.clear("quicksuggest.dynamicSuggestionTypes"); await QuickSuggest.rustBackend.ingestPromise; }); // Ingestion should be performed according to the defined interval. add_task(async function interval() { // Re-enable the backend with a small ingest interval. A new ingest will // immediately start. let intervalSecs = 3; UrlbarPrefs.set("quicksuggest.rustIngestIntervalSeconds", intervalSecs); UrlbarPrefs.set("quicksuggest.rustEnabled", false); UrlbarPrefs.set("quicksuggest.rustEnabled", true); info("Awaiting initial ingest promise"); let { ingestPromise } = QuickSuggest.rustBackend; await ingestPromise; let enabledTypes = QuickSuggest.rustBackend._test_enabledSuggestionTypes; Assert.greater( enabledTypes.length, 0, "This test expects some Rust suggestion types to be enabled" ); await withIngestStub(async ({ stub }) => { // Wait for a few ingests to happen due to the timer firing. for (let i = 0; i < 3; i++) { info(`Waiting ${intervalSecs}s for ingest to start at index ${i}`); ({ ingestPromise } = await waitForIngestStart(ingestPromise)); info("Waiting for ingest to finish at index " + i); await ingestPromise; info("Ingest finished at index " + i); checkIngestCounts({ stub, expected: Object.fromEntries( enabledTypes.map(({ provider }) => [provider, 1]) ), }); } }); info("Disabling the backend"); UrlbarPrefs.set("quicksuggest.rustEnabled", false); // At this point, ingests should stop with two caveats. (1) There may be one // ongoing ingest that started immediately after `ingestPromise` resolved in // the final iteration of the loop above. (2) The timer manager sometimes // fires our ingest timer even after it was unregistered by the backend (when // the backend was disabled), maybe because the interval is so small in this // test. These two things mean that up to two more ingests may finish now. // We'll simply wait for a few seconds up to two times until no new ingests // start. let waitSecs = 2 * intervalSecs; // eslint-disable-next-line mozilla/no-arbitrary-setTimeout let wait = () => new Promise(r => setTimeout(r, 1000 * waitSecs)); let waitedAtEndOfLoop = false; for (let i = 0; i < 2; i++) { info(`Waiting ${waitSecs}s after disabling backend, i=${i}...`); await wait(); let { ingestPromise: newIngestPromise } = QuickSuggest.rustBackend; if (ingestPromise == newIngestPromise) { info(`No new ingest started, i=${i}`); waitedAtEndOfLoop = true; break; } info(`New ingest started, now awaiting, i=${i}`); ingestPromise = newIngestPromise; await ingestPromise; } if (!waitedAtEndOfLoop) { info(`Waiting a final ${waitSecs}s...`); await wait(); } // No new ingests should have started. Assert.equal( QuickSuggest.rustBackend.ingestPromise, ingestPromise, "No new ingest started after disabling the backend" ); // Clean up for later tasks: Reset the interval and enable the backend again. UrlbarPrefs.clear("quicksuggest.rustIngestIntervalSeconds"); UrlbarPrefs.set("quicksuggest.rustEnabled", true); info("Awaiting cleanup ingest promise"); await QuickSuggest.rustBackend.ingestPromise; info("Done awaiting cleanup ingest promise"); }); // `SuggestStore.interrupt()` should be called on shutdown. add_task(async function shutdown() { let sandbox = sinon.createSandbox(); let spy = sandbox.spy(QuickSuggest.rustBackend._test_store, "interrupt"); Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); AsyncShutdown.profileChangeTeardown._trigger(); let calls = spy.getCalls(); Assert.equal( calls.length, 1, "store.interrupt() should have been called once on simulated shutdown" ); Assert.deepEqual( calls[0].args, [InterruptKind.READ_WRITE], "store.interrupt() should have been called with InterruptKind.READ_WRITE" ); Assert.ok( InterruptKind.READ_WRITE, "Sanity check: InterruptKind.READ_WRITE is defined" ); Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); sandbox.restore(); }); /** * Stubs `SuggestStore.ingest()` and calls your callback. * * @param {Function} callback * Callback */ async function withIngestStub(callback) { let sandbox = sinon.createSandbox(); let { rustBackend } = QuickSuggest; let stub = sandbox.stub(rustBackend._test_store, "ingest"); // `ingest()` returns a `SuggestIngestionMetrics` object. stub.returns( new SuggestIngestionMetrics({ ingestionTimes: [], downloadTimes: [] }) ); await callback({ stub, rustBackend }); sandbox.restore(); } /** * Gets `ingest()` call counts per Rust suggestion provider. Also resets the * call counts before returning. * * @param {stub} stub * Sinon `ingest()` stub. * @param {Array} providersFilter * Array of provider integers to filter in. If null, ingest counts from all * providers will be returned. * @returns {object} * An plain JS object that maps provider integers to ingest counts. */ function getIngestCounts(stub, providersFilter = null) { let countsByProvider = {}; for (let call of stub.getCalls()) { let ingestConstraints = call.args[0]; for (let p of ingestConstraints.providers) { if (!providersFilter || providersFilter.includes(p)) { if (!countsByProvider.hasOwnProperty(p)) { countsByProvider[p] = 0; } countsByProvider[p]++; } } } info("Got ingest counts: " + JSON.stringify(countsByProvider)); stub.resetHistory(); return countsByProvider; } function checkIngestCounts({ stub, providersFilter, expected }) { Assert.deepEqual( getIngestCounts(stub, providersFilter), expected, "Actual ingest counts should match expected counts" ); } async function waitForIngestStart(oldIngestPromise) { let newIngestPromise; await TestUtils.waitForCondition(() => { let { ingestPromise } = QuickSuggest.rustBackend; if ( (oldIngestPromise && ingestPromise != oldIngestPromise) || (!oldIngestPromise && ingestPromise) ) { newIngestPromise = ingestPromise; return true; } return false; }, "Waiting for a new ingest to start"); Assert.equal( QuickSuggest.rustBackend.ingestPromise, newIngestPromise, "Sanity check: ingestPromise hasn't changed since waitForCondition returned" ); // A bare promise can't be returned because it will cause the awaiting caller // to await that promise! We're simply trying to return the promise, which the // caller can later await. return { ingestPromise: newIngestPromise }; } /** * 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", ""); }