Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

"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(
);
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": `<!DOCTYPE html><script src="extpage.js"></script>`,
"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`
);
});