"use strict"; ChromeUtils.defineESModuleGetters(this, { ExtensionPreferencesManager: "resource://gre/modules/ExtensionPreferencesManager.sys.mjs", }); AddonTestUtils.init(this); AddonTestUtils.overrideCertDB(); AddonTestUtils.createAppInfo( "xpcshell@tests.mozilla.org", "XPCShell", "42", "42" ); Services.prefs.setBoolPref("extensions.eventPages.enabled", true); // Set minimum idle timeout for testing Services.prefs.setIntPref("extensions.background.idle.timeout", 0); // Expected rejection from the test cases defined in this file. PromiseTestUtils.allowMatchingRejectionsGlobally(/expected-test-rejection/); PromiseTestUtils.allowMatchingRejectionsGlobally( /Actor 'Conduits' destroyed before query 'RunListener' was resolved/ ); add_setup(async () => { await AddonTestUtils.promiseStartupManager(); }); add_task(async function test_eventpage_idle() { const { GleanCustomDistribution } = globalThis; resetTelemetryData(); assertHistogramEmpty(WEBEXT_EVENTPAGE_RUNNING_TIME_MS); assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID); assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); assertGleanMetricsNoSamples({ metricId: "eventPageRunningTime", gleanMetric: Glean.extensionsTiming.eventPageRunningTime, gleanMetricConstructor: GleanCustomDistribution, }); assertGleanLabeledCounterEmpty({ metricId: "eventPageIdleResult", gleanMetric: Glean.extensionsCounters.eventPageIdleResult, gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, }); let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { permissions: ["browserSettings"], background: { persistent: false }, }, background() { browser.browserSettings.allowPopupsForUserEvents.onChange.addListener( () => { browser.test.sendMessage("allowPopupsForUserEvents"); } ); browser.runtime.onSuspend.addListener(async () => { let setting = await browser.browserSettings.allowPopupsForUserEvents.get({}); browser.test.sendMessage("suspended", setting); }); }, }); await extension.startup(); assertPersistentListeners( extension, "browserSettings", "allowPopupsForUserEvents", { primed: false, } ); info(`test idle timeout after startup`); await extension.awaitMessage("suspended"); await promiseExtensionEvent(extension, "shutdown-background-script"); assertPersistentListeners( extension, "browserSettings", "allowPopupsForUserEvents", { primed: true, } ); ExtensionPreferencesManager.setSetting( extension.id, "allowPopupsForUserEvents", "click" ); await extension.awaitMessage("allowPopupsForUserEvents"); ok(true, "allowPopupsForUserEvents.onChange fired"); // again after the event is fired info(`test idle timeout after wakeup`); let setting = await extension.awaitMessage("suspended"); equal(setting.value, true, "verify simple async wait works in onSuspend"); await promiseExtensionEvent(extension, "shutdown-background-script"); assertPersistentListeners( extension, "browserSettings", "allowPopupsForUserEvents", { primed: true, } ); ExtensionPreferencesManager.setSetting( extension.id, "allowPopupsForUserEvents", false ); await extension.awaitMessage("allowPopupsForUserEvents"); ok(true, "allowPopupsForUserEvents.onChange fired"); const { id } = extension; await extension.unload(); info("Verify eventpage telemetry recorded"); assertHistogramSnapshot( WEBEXT_EVENTPAGE_RUNNING_TIME_MS, { keyed: false, processSnapshot: snapshot => snapshot.sum > 0, expectedValue: true, }, `Expect stored values in the eventpage running time non-keyed histogram snapshot` ); assertHistogramSnapshot( WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID, { keyed: true, processSnapshot: snapshot => snapshot[id]?.sum > 0, expectedValue: true, }, `Expect stored values for addon with id ${id} in the eventpage running time keyed histogram snapshot` ); assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { category: "suspend", categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, }); assertGleanLabeledCounterNotEmpty({ metricId: "eventPageIdleResult", gleanMetric: Glean.extensionsCounters.eventPageIdleResult, expectedNotEmptyLabels: ["suspend"], }); assertHistogramCategoryNotEmpty( WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, { keyed: true, key: id, category: "suspend", categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, } ); Assert.greater( Glean.extensionsTiming.eventPageRunningTime.testGetValue()?.sum, 0, `Expect stored values in the eventPageRunningTime Glean metric` ); }); add_task( { pref_set: [["extensions.background.idle.timeout", 500]] }, async function test_eventpage_runtime_parentApiCall_resets_timeout() { resetTelemetryData(); assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); assertGleanLabeledCounterEmpty({ metricId: "eventPageIdleResult", gleanMetric: Glean.extensionsCounters.eventPageIdleResult, gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, }); let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { background: { persistent: false }, }, async background() { let start = Date.now(); browser.runtime.onSuspend.addListener(() => { browser.test.sendMessage("done", Date.now() - start); }); browser.runtime.getBrowserInfo(); // eslint-disable-next-line mozilla/no-arbitrary-setTimeout setTimeout(() => browser.runtime.getBrowserInfo(), 50); }, }); await extension.startup(); let [, resetData] = await promiseExtensionEvent( extension, "background-script-reset-idle" ); equal(resetData.reason, "parentApiCall", "Got the expected idle reset."); await promiseExtensionEvent(extension, "shutdown-background-script"); let time = await extension.awaitMessage("done"); Assert.greater(time, 100, `Background script suspended after ${time}ms.`); // Disabled because the telemetry is too chatty, see bug 1868960. // assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { // category: "reset_parentapicall", // categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, // }); // assertHistogramCategoryNotEmpty( // WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, // { // keyed: true, // key: extension.id, // category: "reset_parentapicall", // categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, // } // ); // assertGleanLabeledCounterNotEmpty({ // metricId: "eventPageIdleResult", // gleanMetric: Glean.extensionsCounters.eventPageIdleResult, // expectedNotEmptyLabels: ["reset_parentapicall"], // }); await extension.unload(); } ); add_task( { pref_set: [["extensions.background.idle.timeout", 500]] }, async function test_extension_page_reset_idle() { let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { background: { persistent: false }, }, background() { browser.test.log("background script start"); browser.runtime.onSuspend.addListener(() => { browser.test.sendMessage("suspended"); }); browser.test.sendMessage("ready"); }, files: { "page.html": "", async "page.js"() { await browser.runtime.getBrowserInfo(); browser.test.sendMessage("page-done"); }, }, }); await extension.startup(); // Need to set up the listener as early as possible. let closed = promiseExtensionEvent(extension, "shutdown-background-script"); await extension.awaitMessage("ready"); info("Background script ready."); extension.extension.once("background-script-reset-idle", () => { ok(false, "background-script-reset-idle emitted from an extension page."); }); let page = await ExtensionTestUtils.loadContentPage( extension.extension.baseURI.resolve("page.html") ); await extension.awaitMessage("page-done"); info("Test page loaded."); await closed; await extension.awaitMessage("suspended"); ok(true, "API call from extension page did not reset idle timeout."); await page.close(); await extension.unload(); } ); add_task(async function test_persistent_background_reset_idle() { let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { background: { persistent: true }, }, background() { browser.test.onMessage.addListener(async () => { await browser.runtime.getBrowserInfo(); browser.test.sendMessage("done"); }); browser.test.sendMessage("ready"); }, }); await extension.startup(); await extension.awaitMessage("ready"); extension.extension.once("background-script-reset-idle", () => { ok(false, "background-script-reset-idle from persistent background page."); }); extension.sendMessage("call-parent-api"); ok(true, "API call from persistent background did not reset idle timeout."); await extension.awaitMessage("done"); await extension.unload(); }); add_task( { pref_set: [["extensions.webextensions.runtime.timeout", 500]] }, async function test_eventpage_runtime_onSuspend_timeout() { let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { background: { persistent: false }, }, background() { browser.runtime.onSuspend.addListener(() => { // return a promise that never resolves return new Promise(() => {}); }); }, }); await extension.startup(); await promiseExtensionEvent(extension, "shutdown-background-script"); ok(true, "onSuspend did not block background shutdown"); await extension.unload(); } ); add_task( { pref_set: [["extensions.webextensions.runtime.timeout", 500]] }, async function test_eventpage_runtime_onSuspend_reject() { let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { background: { persistent: false }, }, background() { browser.runtime.onSuspend.addListener(() => { // Raise an error to test error handling in onSuspend return Promise.reject("testing reject"); }); }, }); await extension.startup(); await promiseExtensionEvent(extension, "shutdown-background-script"); ok(true, "onSuspend did not block background shutdown"); await extension.unload(); } ); add_task( { pref_set: [["extensions.webextensions.runtime.timeout", 1000]] }, async function test_eventpage_runtime_onSuspend_canceled() { resetTelemetryData(); assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); assertGleanLabeledCounterEmpty({ metricId: "eventPageIdleResult", gleanMetric: Glean.extensionsCounters.eventPageIdleResult, gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, }); let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { permissions: ["browserSettings"], background: { persistent: false }, }, background() { let resolveSuspend; browser.browserSettings.allowPopupsForUserEvents.onChange.addListener( () => { browser.test.sendMessage("allowPopupsForUserEvents"); } ); browser.runtime.onSuspend.addListener(() => { browser.test.sendMessage("suspending"); return new Promise(resolve => { resolveSuspend = resolve; }); }); browser.runtime.onSuspendCanceled.addListener(() => { browser.test.sendMessage("suspendCanceled"); }); browser.test.onMessage.addListener(() => { resolveSuspend(); }); }, }); await extension.startup(); await extension.awaitMessage("suspending"); // While suspending, cause an event ExtensionPreferencesManager.setSetting( extension.id, "allowPopupsForUserEvents", "click" ); extension.sendMessage("resolveSuspend"); await extension.awaitMessage("allowPopupsForUserEvents"); await extension.awaitMessage("suspendCanceled"); ok(true, "event caused suspend-canceled"); // Disabled because the telemetry is too chatty, see bug 1868960. // assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { // category: "reset_event", // categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, // }); // assertGleanLabeledCounterNotEmpty({ // metricId: "eventPageIdleResult", // gleanMetric: Glean.extensionsCounters.eventPageIdleResult, // expectedNotEmptyLabels: ["reset_event"], // }); // assertHistogramCategoryNotEmpty( // WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, // { // keyed: true, // key: extension.id, // category: "reset_event", // categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, // } // ); await extension.awaitMessage("suspending"); await promiseExtensionEvent(extension, "shutdown-background-script"); await extension.unload(); } ); add_task(async function test_terminateBackground_after_extension_hasShutdown() { let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { background: { persistent: false }, }, async background() { browser.runtime.onSuspend.addListener(() => { browser.test.fail( `runtime.onSuspend listener should have not been called` ); }); // Call an API method implemented in the parent process (to be sure runtime.onSuspend // listener is going to be fully registered from a parent process perspective by the // time we will send the "bg-ready" test message). await browser.runtime.getBrowserInfo(); browser.test.sendMessage("bg-ready"); }, }); await extension.startup(); await extension.awaitMessage("bg-ready"); // Fake suspending event page on idle while the extension was being shutdown by manually // setting the hasShutdown flag to true on the Extension class instance object. extension.extension.hasShutdown = true; await extension.terminateBackground(); extension.extension.hasShutdown = false; await extension.unload(); }); add_task(async function test_wakeupBackground_after_extension_hasShutdown() { let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { background: { persistent: false }, }, async background() { browser.test.sendMessage("bg-ready"); }, }); await extension.startup(); await extension.awaitMessage("bg-ready"); await extension.terminateBackground(); // Fake suspending event page on idle while the extension was being shutdown by manually // setting the hasShutdown flag to true on the Extension class instance object. extension.extension.hasShutdown = true; await Assert.rejects( extension.wakeupBackground(), /wakeupBackground called while the extension was already shutting down/, "Got the expected rejection when wakeupBackground is called after extension shutdown" ); extension.extension.hasShutdown = false; await extension.unload(); }); async function testSuspendShutdownRace({ manifest_version }) { const extension = ExtensionTestUtils.loadExtension({ manifest: { manifest_version, background: manifest_version === 2 ? { persistent: false } : {}, permissions: ["webRequest", "webRequestBlocking"], host_permissions: ["*://example.com/*"], granted_host_permissions: true, }, // Define an empty background script. background() {}, }); await extension.startup(); await extension.extension.promiseBackgroundStarted(); const promiseTerminateBackground = extension.extension.terminateBackground(); // Wait one tick to leave to terminateBackground async method time to get // past the first check that returns earlier if extension.hasShutdown is true. await Promise.resolve(); const promiseUnload = extension.unload(); await promiseUnload; try { await promiseTerminateBackground; ok(true, "extension.terminateBackground should not have been rejected"); } catch (err) { ok( false, `extension.terminateBackground should not have been rejected: ${err} :: ${err.stack}` ); } } add_task(function test_mv2_suspend_shutdown_race() { return testSuspendShutdownRace({ manifest_version: 2 }); }); add_task( { pref_set: [["extensions.manifestV3.enabled", true]], }, function test_mv3_suspend_shutdown_race() { return testSuspendShutdownRace({ manifest_version: 3 }); } ); function createPendingListenerTestExtension() { return ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { permissions: ["browserSettings"], background: { persistent: false }, }, background() { let idx = 0; browser.browserSettings.allowPopupsForUserEvents.onChange.addListener( async () => { const currIdx = idx++; await new Promise((resolve, reject) => { browser.test.onMessage.addListener(msg => { switch (`${msg}-${currIdx}`) { case "unblock-promise-0": resolve(); browser.test.sendMessage("allowPopupsForUserEvents:resolved"); break; case "unblock-promise-1": reject(new Error("expected-test-rejection")); browser.test.sendMessage("allowPopupsForUserEvents:rejected"); break; default: browser.test.fail(`Unexpected test message: ${msg}`); } }); browser.test.sendMessage("allowPopupsForUserEvents:awaiting"); }); } ); browser.runtime.onSuspend.addListener(() => { // Raise an error to test error handling in onSuspend return browser.test.sendMessage("runtime-on-suspend"); }); browser.test.sendMessage("bg-script-ready"); }, }); } add_task( { pref_set: [["extensions.background.idle.timeout", 500]] }, async function test_eventpage_idle_reset_on_async_listener_unresolved() { resetTelemetryData(); assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); assertGleanLabeledCounterEmpty({ metricId: "eventPageIdleResult", gleanMetric: Glean.extensionsCounters.eventPageIdleResult, gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, }); let extension = createPendingListenerTestExtension(); await extension.startup(); await extension.awaitMessage("bg-script-ready"); info("Trigger the first API event listener call"); ExtensionPreferencesManager.setSetting( extension.id, "allowPopupsForUserEvents", "click" ); await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); info("Trigger the second API event listener call"); ExtensionPreferencesManager.setSetting( extension.id, "allowPopupsForUserEvents", "click" ); await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); info("Wait for suspend on idle to be reset"); const [, resetIdleData] = await promiseExtensionEvent( extension, "background-script-reset-idle" ); Assert.deepEqual( resetIdleData, { reason: "pendingListeners", pendingListeners: 2, }, "Got the expected idle reset reason and pendingListeners count" ); assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { category: "reset_listeners", categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, }); assertGleanLabeledCounter({ metricId: "eventPageIdleResult", gleanMetric: Glean.extensionsCounters.eventPageIdleResult, gleanMetricLabels: GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES, ignoreNonExpectedLabels: true, // Only check values on the labels listed below. expectedLabelsValue: { reset_listeners: 1, }, }); assertHistogramCategoryNotEmpty( WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, { keyed: true, key: extension.id, category: "reset_listeners", categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, } ); info( "Resolve the async listener pending on a promise and expect the event page to suspend after the idle timeout" ); extension.sendMessage("unblock-promise"); // Expect the two promises to be resolved and rejected respectively. await extension.awaitMessage("allowPopupsForUserEvents:resolved"); await extension.awaitMessage("allowPopupsForUserEvents:rejected"); info("Await for the runtime.onSuspend event to be emitted"); await extension.awaitMessage("runtime-on-suspend"); await extension.unload(); } ); add_task( { pref_set: [["extensions.background.idle.timeout", 500]] }, async function test_pending_async_listeners_promises_rejected_on_shutdown() { let extension = createPendingListenerTestExtension(); await extension.startup(); await extension.awaitMessage("bg-script-ready"); info("Trigger the API event listener call"); ExtensionPreferencesManager.setSetting( extension.id, "allowPopupsForUserEvents", "click" ); await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); const { runListenerPromises } = extension.extension.backgroundContext; equal( runListenerPromises.size, 1, "Got the expected number of pending runListener promises" ); const pendingPromise = Array.from(runListenerPromises)[0]; // Shutdown the extension while there is still a pending promises being tracked // to verify they gets rejected as expected when the background page browser element // is going to be destroyed. await extension.unload(); await Assert.rejects( pendingPromise, /Actor 'Conduits' destroyed before query 'RunListener' was resolved/, "Previously pending runListener promise rejected with the expected error" ); equal( runListenerPromises.size, 0, "Expect no remaining pending runListener promises" ); } ); add_task( { pref_set: [["extensions.background.idle.timeout", 500]] }, async function test_eventpage_idle_reset_once_on_pending_async_listeners() { let extension = createPendingListenerTestExtension(); await extension.startup(); await extension.awaitMessage("bg-script-ready"); info("Trigger the API event listener call"); ExtensionPreferencesManager.setSetting( extension.id, "allowPopupsForUserEvents", "click" ); await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); info("Wait for suspend on the first idle timeout to be reset"); const [, resetIdleData] = await promiseExtensionEvent( extension, "background-script-reset-idle" ); Assert.deepEqual( resetIdleData, { reason: "pendingListeners", pendingListeners: 1, }, "Got the expected idle reset reason and pendingListeners count" ); info( "Await for the runtime.onSuspend event to be emitted on the second idle timeout hit" ); // We expect this part of the test to trigger a uncaught rejection for the // "Actor 'Conduits' destroyed before query 'RunListener' was resolved" error, // due to the listener left purposely pending in this test // and so that expected rejection is ignored using PromiseTestUtils in the preamble // of this test file. await extension.awaitMessage("runtime-on-suspend"); await extension.unload(); } );