"use strict"; ChromeUtils.defineESModuleGetters(this, { ExtensionMenus: "resource://gre/modules/ExtensionMenus.sys.mjs", KeyValueService: "resource://gre/modules/kvstore.sys.mjs", Management: "resource://gre/modules/Extension.sys.mjs", }); const { AddonTestUtils } = ChromeUtils.importESModule( "resource://testing-common/AddonTestUtils.sys.mjs" ); AddonTestUtils.init(this); AddonTestUtils.overrideCertDB(); AddonTestUtils.createAppInfo( "xpcshell@tests.mozilla.org", "XPCShell", "42", "42" ); Services.prefs.setBoolPref("extensions.eventPages.enabled", true); function getExtension(id, background, useAddonManager, version = "1.0") { return { useAddonManager, manifest: { version, browser_specific_settings: { gecko: { id } }, permissions: ["menus"], background: { persistent: false }, }, background, }; } async function expectPersistedMenus(extensionId, extensionVersion, expect) { let menusFromStore = await ExtensionMenus._getStoredMenusForTesting( extensionId, extensionVersion ); equal(menusFromStore.size, expect.length, "stored menus size"); let createProperties = Array.from(menusFromStore.values()); // The menus are loaded from disk for extensions using a non-persistent // background page and kept in memory in a map, the order is significant // for recreating menus on startup. Ensure that they are in // the expected order. for (let i in createProperties) { Assert.deepEqual( createProperties[i], expect[i], "expected properties exist in the menus store" ); } } async function expectExtensionMenus( testExtension, expect, { checkSaved } = {} ) { const extension = WebExtensionPolicy.getByID(testExtension.id).extension; let menusInMemory = ExtensionMenus.getMenus(extension); let createProperties = Array.from(menusInMemory.values()); equal(menusInMemory.size, expect.length, "menus map size"); for (let i in createProperties) { Assert.deepEqual( createProperties[i], expect[i], "expected properties exist in the menus map" ); } if (!checkSaved) { return; } await expectPersistedMenus(testExtension.id, testExtension.version, expect); } function promiseExtensionEvent(wrapper, event) { return new Promise(resolve => { wrapper.extension.once(event, (kind, data) => { resolve(data); }); }); } async function mockBrowserRestart( extTestWrapper, { shutdownAndRecreateStore = true, waitForMenuRecreated = true } = {} ) { if (shutdownAndRecreateStore) { info("Mock browser shutdown"); let menusManager = ExtensionMenus._getManager(extTestWrapper.extension); await AddonTestUtils.promiseShutdownManager(); // Wait data to be flushed as part of the extension shutdown and recreate the store. info("Wait for store to be flushed"); await menusManager._finalizeStoreTaskForTesting(); info("Recreate menus store"); ExtensionMenus._recreateStoreForTesting(); } let promiseMenusRecreated; if (waitForMenuRecreated) { Management.once("startup", (kind, ext) => { info(`management ${kind} ${ext.id}`); promiseMenusRecreated = promiseExtensionEvent( { extension: ext }, "webext-menus-created" ); }); } info("Mock browser startup"); await AddonTestUtils.promiseStartupManager(); await extTestWrapper.awaitStartup(); if (waitForMenuRecreated) { info("Wait for persisted menus to be recreated"); } await promiseMenusRecreated; } function extPageScriptWithMenusCreateAndUpdateTestHandler() { browser.test.onMessage.addListener((msg, ...args) => { switch (msg) { case "menusCreate": { const menuDetails = args[0]; browser.menus.create(menuDetails, () => { browser.test.assertEq( undefined, browser.runtime.lastError?.message, "Expect the menu to be created successfully" ); browser.test.sendMessage(`${msg}:done`); }); break; } case "menusUpdate": { const menuId = args[0]; const menuDetails = args[1]; browser.test.log(`Updating "${menuId}: ${JSON.stringify(menuDetails)}`); browser.menus.update(menuId, menuDetails, () => { browser.test.assertEq( undefined, browser.runtime.lastError?.message, "Expect the menu to be created successfully" ); browser.test.sendMessage(`${msg}:done`); }); break; } default: browser.test.fail(`Got unexpected test message: ${msg}`); browser.test.sendMessage(`${msg}:done`); } }); browser.test.sendMessage("extpage:ready"); } add_setup(async () => { // Reduce the amount of time we wait to write menus // data on disk while running this test. Services.prefs.setIntPref( "extensions.webextensions.menus.writeDebounceTime", 200 ); await AddonTestUtils.promiseStartupManager(); }); add_task(async function test_menu_onInstalled() { async function background() { browser.runtime.onInstalled.addListener(async () => { const parentId = browser.menus.create({ contexts: ["all"], title: "parent", id: "test-parent", }); browser.menus.create({ parentId, title: "click A", id: "test-click-a", }); browser.menus.create( { parentId, title: "click B", id: "test-click-b", }, () => { browser.test.sendMessage("onInstalled"); } ); }); browser.menus.create( { contexts: ["tab"], title: "top-level", id: "test-top-level", }, () => { browser.test.sendMessage("create", browser.runtime.lastError?.message); } ); browser.test.onMessage.addListener(async msg => { browser.test.log(`onMessage ${msg}`); if (msg == "updatemenu") { await browser.menus.update("test-click-a", { title: "click updated" }); } else if (msg == "removemenu") { await browser.menus.remove("test-click-b"); } else if (msg == "removeall") { await browser.menus.removeAll(); } browser.test.sendMessage("updated"); }); } const extension = ExtensionTestUtils.loadExtension( getExtension("test-persist@mochitest", background, "permanent") ); await extension.startup(); let lastError = await extension.awaitMessage("create"); Assert.equal(lastError, undefined, "no error creating menu"); await extension.awaitMessage("onInstalled"); await extension.terminateBackground(); await expectExtensionMenus(extension, [ { contexts: ["tab"], id: "test-top-level", title: "top-level", }, { contexts: ["all"], id: "test-parent", title: "parent" }, { id: "test-click-a", parentId: "test-parent", title: "click A", }, { id: "test-click-b", parentId: "test-parent", title: "click B", }, ]); await extension.wakeupBackground(); lastError = await extension.awaitMessage("create"); Assert.equal( lastError, "The menu id test-top-level already exists in menus.create.", "correct error creating menu" ); await mockBrowserRestart(extension); // After the extension or the AddonManager has been shutdown, // we expect the menu store task to have written the menus // on disk and so we expect these menus to be loaded back // in memory and also stored on disk. await expectExtensionMenus( extension, [ { contexts: ["tab"], id: "test-top-level", title: "top-level", }, { contexts: ["all"], id: "test-parent", title: "parent" }, { id: "test-click-a", parentId: "test-parent", title: "click A", }, { id: "test-click-b", parentId: "test-parent", title: "click B", }, ], { checkSaved: true } ); equal( extension.extension.backgroundState, "stopped", "background is not running" ); await extension.wakeupBackground(); lastError = await extension.awaitMessage("create"); Assert.equal( lastError, "The menu id test-top-level already exists in menus.create.", "correct error creating menu" ); let promisePersistedMenusUpdated = TestUtils.topicObserved( "webext-persisted-menus-updated" ); extension.sendMessage("updatemenu"); await extension.awaitMessage("updated"); await extension.terminateBackground(); // Title change is persisted. // (awaiting on the promise resolved when the ExtensionMenus // DeferredTask are been executed and the data expected to be // written on disk, to confirm the menu data is being persisted // on disk also while the extension and the app are still // running). await promisePersistedMenusUpdated; await expectExtensionMenus( extension, [ { contexts: ["tab"], id: "test-top-level", title: "top-level", }, { contexts: ["all"], id: "test-parent", title: "parent" }, { id: "test-click-a", parentId: "test-parent", title: "click updated", }, { id: "test-click-b", parentId: "test-parent", title: "click B", }, ], { checkSaved: true } ); await extension.wakeupBackground(); lastError = await extension.awaitMessage("create"); Assert.equal( lastError, "The menu id test-top-level already exists in menus.create.", "correct error creating menu" ); extension.sendMessage("removemenu"); await extension.awaitMessage("updated"); await extension.terminateBackground(); // menu removed await expectExtensionMenus(extension, [ { contexts: ["tab"], id: "test-top-level", title: "top-level", }, { contexts: ["all"], id: "test-parent", title: "parent" }, { id: "test-click-a", parentId: "test-parent", title: "click updated", }, ]); await extension.wakeupBackground(); lastError = await extension.awaitMessage("create"); Assert.equal( lastError, "The menu id test-top-level already exists in menus.create.", "correct error creating menu" ); promisePersistedMenusUpdated = TestUtils.topicObserved( "webext-persisted-menus-updated" ); extension.sendMessage("removeall"); await extension.awaitMessage("updated"); await extension.terminateBackground(); // menus removed // We expect the persisted menus store to still have an // entry for the extension even when all menus have been // removed. await promisePersistedMenusUpdated; equal( await ExtensionMenus._hasStoredExtensionData(extension.id), true, "persisted menus store have an entry for the test extension" ); await expectExtensionMenus(extension, [], { checkSaved: true }); promisePersistedMenusUpdated = TestUtils.topicObserved( "webext-persisted-menus-updated" ); await extension.unload(); await promisePersistedMenusUpdated; // Expect the entry to have been removed completely from // the persised menus store the test extension is uninstalled. equal( await ExtensionMenus._hasStoredExtensionData(extension.id), false, "uninstalled extension should NOT have an entry in the persisted menus store" ); }); add_task(async function test_menu_persisted_cleared_after_ext_update() { async function background() { browser.test.onMessage.addListener(async (action, properties) => { browser.test.log(`onMessage ${action}`); switch (action) { case "create": await new Promise(resolve => { browser.menus.create(properties, resolve); }); break; default: browser.test.fail(`Got unexpected test message "${action}"`); break; } browser.test.sendMessage("updated"); }); } const extension = ExtensionTestUtils.loadExtension( getExtension("test-nesting@mochitest", background, "permanent", "1.0") ); await extension.startup(); extension.sendMessage("create", { id: "stored-menu", contexts: ["all"], title: "some-menu", }); await extension.awaitMessage("updated"); const expectedMenus = [ { contexts: ["all"], id: "stored-menu", title: "some-menu" }, ]; await expectExtensionMenus(extension, expectedMenus); info( "Re-install the same add-on version and expect persisted menus to still exist" ); await extension.upgrade( getExtension("test-nesting@mochitest", background, "permanent", "1.0") ); await expectExtensionMenus(extension, expectedMenus); info( "Upgrade to a new add-on version and expect persisted menus to be cleared" ); await extension.upgrade( getExtension("test-nesting@mochitest", background, "permanent", "2.0") ); await expectExtensionMenus(extension, []); await extension.unload(); }); add_task(async function test_menu_nested() { async function background() { browser.test.onMessage.addListener(async (action, properties) => { browser.test.log(`onMessage ${action}`); switch (action) { case "create": await new Promise(resolve => { browser.menus.create(properties, resolve); }); break; case "update": { let { id, ...update } = properties; await browser.menus.update(id, update); } break; case "remove": { let { id } = properties; await browser.menus.remove(id); } break; case "removeAll": await browser.menus.removeAll(); break; } browser.test.sendMessage("updated"); }); } const extension = ExtensionTestUtils.loadExtension( getExtension("test-nesting@mochitest", background, "permanent") ); await extension.startup(); extension.sendMessage("create", { id: "first", contexts: ["all"], title: "first", }); await extension.awaitMessage("updated"); await expectExtensionMenus(extension, [ { contexts: ["all"], id: "first", title: "first" }, ]); extension.sendMessage("create", { id: "second", contexts: ["all"], title: "second", }); await extension.awaitMessage("updated"); await expectExtensionMenus(extension, [ { contexts: ["all"], id: "first", title: "first" }, { contexts: ["all"], id: "second", title: "second" }, ]); extension.sendMessage("create", { id: "third", contexts: ["all"], title: "third", parentId: "first", }); await extension.awaitMessage("updated"); await expectExtensionMenus(extension, [ { contexts: ["all"], id: "first", title: "first" }, { contexts: ["all"], id: "second", title: "second" }, { contexts: ["all"], id: "third", parentId: "first", title: "third", }, ]); extension.sendMessage("create", { id: "fourth", contexts: ["all"], title: "fourth", }); await extension.awaitMessage("updated"); await expectExtensionMenus(extension, [ { contexts: ["all"], id: "first", title: "first" }, { contexts: ["all"], id: "second", title: "second" }, { contexts: ["all"], id: "third", parentId: "first", title: "third", }, { contexts: ["all"], id: "fourth", title: "fourth" }, ]); extension.sendMessage("update", { id: "first", parentId: "second", }); await extension.awaitMessage("updated"); await expectExtensionMenus(extension, [ { contexts: ["all"], id: "second", title: "second" }, { contexts: ["all"], id: "fourth", title: "fourth" }, { contexts: ["all"], id: "first", title: "first", parentId: "second", }, { contexts: ["all"], id: "third", parentId: "first", title: "third", }, ]); await AddonTestUtils.promiseShutdownManager(); // We need to attach an event listener before the // startup event is emitted. Fortunately, we // emit via Management before emitting on extension. let promiseMenus; Management.once("startup", (kind, ext) => { info(`management ${kind} ${ext.id}`); promiseMenus = promiseExtensionEvent( { extension: ext }, "webext-menus-created" ); }); await AddonTestUtils.promiseStartupManager(); await extension.awaitStartup(); await extension.wakeupBackground(); await expectExtensionMenus( extension, [ { contexts: ["all"], id: "second", title: "second" }, { contexts: ["all"], id: "fourth", title: "fourth" }, { contexts: ["all"], id: "first", title: "first", parentId: "second", }, { contexts: ["all"], id: "third", parentId: "first", title: "third", }, ], { checkSaved: true } ); // validate nesting let menus = await promiseMenus; equal(menus.get("first").parentId, "second", "menuitem parent is correct"); equal( menus.get("second").children.length, 1, "menuitem parent has correct number of children" ); equal( menus.get("second").root.children.length, 2, // second and forth "menuitem root has correct number of children" ); extension.sendMessage("remove", { id: "second", }); await extension.awaitMessage("updated"); await expectExtensionMenus(extension, [ { contexts: ["all"], id: "fourth", title: "fourth" }, ]); extension.sendMessage("removeAll"); await extension.awaitMessage("updated"); await expectExtensionMenus(extension, []); await extension.unload(); }); add_task(async function test_ExtensionMenus_after_extension_hasShutdown() { const assertEmptyMenusManagersMap = () => { let weakMapKeys = ChromeUtils.nondeterministicGetWeakMapKeys( ExtensionMenus._menusManagers ); Assert.deepEqual( weakMapKeys.length, 0, "Expect ExtensionMenus._menusManagers weakmap to be empty" ); }; // Sanity check. assertEmptyMenusManagersMap(); const addonId = "test-menu-after-shutdown@mochitest"; const testExtWrapper = ExtensionTestUtils.loadExtension( getExtension(addonId, () => {}, "permanent") ); await testExtWrapper.startup(); const { extension } = testExtWrapper; Assert.equal( extension.hasShutdown, false, "Extension hasShutdown should be false" ); await testExtWrapper.unload(); Assert.equal( extension.hasShutdown, true, "Extension hasShutdown should be true" ); // Sanity check. assertEmptyMenusManagersMap(); await Assert.rejects( ExtensionMenus.asyncInitForExtension(extension), new RegExp( `Error on creating new ExtensionMenusManager after extension shutdown: ${addonId}` ), "Got the expected error on ExtensionMenus.asyncInitForExtension called for a shutdown extension" ); assertEmptyMenusManagersMap(); Assert.throws( () => ExtensionMenus.getMenus(extension), new RegExp(`No ExtensionMenusManager instance found for ${addonId}`), "Got the expected error on ExtensionMenus.getMenus called for a shutdown extension" ); assertEmptyMenusManagersMap(); }); // This test ensures that menus created by an extension without a background page // are not persisted. add_task(async function test_extension_without_background() { let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { permissions: ["menus"], }, files: { "extpage.html": ``, "extpage.js": extPageScriptWithMenusCreateAndUpdateTestHandler, }, }); async function testCreateMenu() { const extPageUrl = extension.extension.baseURI.resolve("extpage.html"); let page = await ExtensionTestUtils.loadContentPage(extPageUrl); await extension.awaitMessage("extpage:ready"); const menuDetails = { id: "test-menu", title: "menu title" }; extension.sendMessage("menusCreate", menuDetails); await extension.awaitMessage("menusCreate:done"); await page.close(); } await extension.startup(); await testCreateMenu(); info( "Simulated browser restart and verify no menu was persisted or restored" ); await mockBrowserRestart(extension, { waitForMenuRecreated: false }); // Try to create the same menu again, if it does fail the menu was unexpectectly // restored. await testCreateMenu(); equal( await ExtensionMenus._hasStoredExtensionData(extension.id), false, "Extensions without a background page should not have any data stored for their menus" ); await extension.unload(); }); // Verify that corrupted menus store data is handled gracefully. add_task(async function test_corrupted_menus_store_data() { let extension = ExtensionTestUtils.loadExtension({ useAddonManager: "permanent", manifest: { permissions: ["menus"], background: { persistent: false }, }, background: extPageScriptWithMenusCreateAndUpdateTestHandler, }); await extension.startup(); await extension.awaitMessage("extpage:ready"); const menuDetails = { id: "test-menu", title: "menu title" }; const menuDetailsUnsupported = { new_unsupported_property: "fake-prop-value", }; const menuDetailsUpdate = { title: "Updated menu title" }; extension.sendMessage("menusCreate", menuDetails); await extension.awaitMessage("menusCreate:done"); let menus = ExtensionMenus.getMenus(extension.extension); Assert.deepEqual( menus.get("test-menu"), menuDetails, "Got the expected menuDetails from ExtensionMenus.getMenus" ); // Inject invalid menu properties into the store to simulate // restoring menus from menus data stored by a future version // with additional menus properties older versions would not // have support for. // // This test covers additive changes, but technically changes // to the format of existing properties may not be handled // gracefully (but changes to the type/format of existing menu // properties are more likely to be part of a manifest version // update, and so they may be less likely, and downgrades not // officially supported). info("Inject unsupported properties in the persisted menu details"); let store = ExtensionMenus._getStoreForTesting(); menus.set("test-menu", { ...menuDetails, ...menuDetailsUnsupported }); await store.updatePersistedMenus(extension.id, extension.version, menus); equal( await ExtensionMenus._hasStoredExtensionData(extension.id), true, "persisted menus store have an entry for the test extension" ); // Mock a browser restart and verify the unsupported property injected // in the persisted menu data is handled gracefully. await mockBrowserRestart(extension); await extension.awaitMessage("extpage:ready"); menus = ExtensionMenus.getMenus(extension.extension); info("Verify the recreated menu can still be updated as expected"); extension.sendMessage("menusUpdate", menuDetails.id, menuDetailsUpdate); await extension.awaitMessage("menusUpdate:done"); menus = ExtensionMenus.getMenus(extension.extension); Assert.deepEqual( menus.get("test-menu"), Object.assign({}, menuDetails, menuDetailsUnsupported, menuDetailsUpdate), "Got the expected menuDetails from ExtensionMenus.getMenus" ); info("Inject orphan menu entry in the persisted menus data"); store = ExtensionMenus._getStoreForTesting(); const orphanedMenuDetails = { id: "orphaned-test-menu", parentId: "non-existing-parent-id", title: "An orphaned menu item", }; menus.set(orphanedMenuDetails.id, orphanedMenuDetails); await store.updatePersistedMenus(extension.id, extension.version, menus); // Mock a browser restart and verify that orphaned menus // from the persisted menu data are handled gracefully. { const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { await mockBrowserRestart(extension); await extension.awaitMessage("extpage:ready"); }); // Make sure the test is hitting the expected error. AddonTestUtils.checkMessages(messages, { expected: [ { message: new RegExp( `Unexpected error on recreating persisted menu ${orphanedMenuDetails.id} for ${extension.id}` ), }, ], }); } menus = ExtensionMenus.getMenus(extension.extension); info("Verify the recreated menu can still be updated as expected"); extension.sendMessage("menusUpdate", menuDetails.id, menuDetailsUpdate); await extension.awaitMessage("menusUpdate:done"); menus = ExtensionMenus.getMenus(extension.extension); Assert.deepEqual( menus.get("test-menu"), Object.assign({}, menuDetails, menuDetailsUnsupported, menuDetailsUpdate), "Got the expected menuDetails from ExtensionMenus.getMenus" ); info("Verify the orphaned menu has been dropped"); Assert.equal( menus.has(orphanedMenuDetails.id), false, "Expect orphaned menu to not exist anymore" ); info("Verify invalid stored json menus data is handled gracefully"); await AddonTestUtils.promiseShutdownManager(); ExtensionMenus._recreateStoreForTesting(); let menuStorePath = PathUtils.join( PathUtils.profileDir, ExtensionMenus.KVSTORE_DIRNAME ); const kvstore = await KeyValueService.getOrCreateWithOptions( menuStorePath, "menus", { strategy: KeyValueService.RecoveryStrategy.RENAME } ); await kvstore.put(extension.id, "invalid-json-data"); { const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { await mockBrowserRestart(extension, { shutdownAndRecreateStore: false }); await extension.awaitMessage("extpage:ready"); }); // Make sure the test is hitting the expected error. AddonTestUtils.checkMessages(messages, { expected: [ { message: new RegExp( `Error loading ${extension.id} persisted menus: SyntaxError` ), }, ], }); } menus = ExtensionMenus.getMenus(extension.extension); Assert.equal(menus.size, 0, "Expect persisted menus map to be empty"); // Verify new menu can still be created. extension.sendMessage("menusCreate", menuDetails); await extension.awaitMessage("menusCreate:done"); menus = ExtensionMenus.getMenus(extension.extension); Assert.equal(menus.size, 1, "Expect persisted menus map to not be empty"); await extension.unload(); }); // Verify that ExtensionMenus.clearPersistedMenusOnUninstall isn't going // to create an unnecessary menus kvstore directory when that directory does // not exist yet, e.g. because the extension never used the contextMenus API. // This includes builds that do not support the contextMenus API. add_task(async function test_unnecessary_kvstore_dir_not_created() { // Create a new store instance to ensure the lazy store initialization // isn't already executed due to the previous test tasks. await AddonTestUtils.promiseRestartManager(); ExtensionMenus._recreateStoreForTesting(); let menuStorePath = PathUtils.join( PathUtils.profileDir, ExtensionMenus.KVSTORE_DIRNAME ); await IOUtils.remove(menuStorePath, { ignoreAbsent: true, recursive: true }); equal( await IOUtils.exists(menuStorePath), false, `Expect no ${ExtensionMenus.KVSTORE_DIRNAME} in the Gecko profile` ); await ExtensionMenus.clearPersistedMenusOnUninstall("fakeextid@test"); equal( await IOUtils.exists(menuStorePath), false, `Expect no ${ExtensionMenus.KVSTORE_DIRNAME} in the Gecko profile` ); });