describe("Plugin API", function () {
	var win, doc, ZoteroPane, Zotero_Tabs, ZoteroContextPane, _itemsView, infoSection, caches;

	function resetCaches() {
		caches = {};
	}

	function initCache(key) {
		if (!caches[key]) {
			caches[key] = {};
		}
		caches[key].deferred = Zotero.Promise.defer();
		caches[key].result = "";
	}

	async function getCache(key) {
		let cache = caches[key];
		await cache.deferred.promise;
		return cache.result;
	}

	function updateCache(key, value) {
		let cache = caches[key];
		if (!cache) return;
		cache.result = value;
		cache.deferred?.resolve();
	}

	before(async function () {
		win = await loadZoteroPane();
		doc = win.document;
		ZoteroPane = win.ZoteroPane;
		Zotero_Tabs = win.Zotero_Tabs;
		ZoteroContextPane = win.ZoteroContextPane;
		_itemsView = win.ZoteroPane.itemsView;
		infoSection = win.ZoteroPane.itemPane._itemDetails.getPane('info');
	});

	after(function () {
		win.close();
	});
	
	describe("Item pane info box custom section", function () {
		let defaultOption = {
			rowID: "default-test",
			pluginID: "zotero@zotero.org",
			label: {
				l10nID: "general-print",
			},
			onGetData: ({ item }) => {
				let data = `${item.id}`;
				updateCache("onGetData", data);
				return data;
			},
		};

		let waitForRegister = async (option) => {
			initCache("onGetData");
			let getDataPromise = getCache("onGetData");
			let rowID = Zotero.ItemPaneManager.registerInfoRow(option);
			await getDataPromise;
			return rowID;
		};

		let waitForUnregister = async (rowID) => {
			let unregisterPromise = waitForNotifierEvent("refresh", "infobox");
			let success = Zotero.ItemPaneManager.unregisterInfoRow(rowID);
			await unregisterPromise;
			return success;
		};

		beforeEach(async function () {
			resetCaches();
		});

		afterEach(function () {
			Zotero_Tabs.select("zotero-pane");
			Zotero_Tabs.closeAll();
		});

		it("should render custom row and call onGetData hook", async function () {
			initCache("onGetData");
			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);
			
			let getDataPromise = getCache("onGetData");
			let rowID = Zotero.ItemPaneManager.registerInfoRow(defaultOption);
			let result = await getDataPromise;

			// Should render custom row
			let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			assert.exists(rowElem);

			// Should call onGetData and render
			let valueElem = rowElem.querySelector(".value");
			assert.equal(result, valueElem.value);

			await waitForUnregister(rowID);
		});
		
		it("should call onSetData hook", async function () {
			let option = Object.assign({}, defaultOption, {
				onSetData: ({ value }) => {
					let data = `${value}`;
					updateCache("onSetData", data);
				},
			});

			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);
			
			let rowID = await waitForRegister(option);

			let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			let valueElem = rowElem.querySelector(".value");

			// Should call onSetData on value change
			initCache("onSetData");
			let setDataPromise = getCache("onSetData");
			let newValue = `TEST CUSTOM ROW`;
			valueElem.focus();
			valueElem.value = newValue;
			let blurEvent = new Event("blur");
			valueElem.dispatchEvent(blurEvent);
			let result = await setDataPromise;
			assert.equal(newValue, result);

			await waitForUnregister(rowID);
		});

		it("should call onItemChange hook", async function () {
			let option = Object.assign({}, defaultOption, {
				onItemChange: ({ item, tabType, setEnabled, setEditable }) => {
					let editable = item.itemType === "book";
					let enabled = tabType === "library";
					setEnabled(enabled);
					setEditable(editable);
					let data = { editable, enabled };
					updateCache("onItemChange", data);
				}
			});

			initCache("onItemChange");

			let bookItem = new Zotero.Item('book');
			await bookItem.saveTx();
			await ZoteroPane.selectItem(bookItem.id);
			
			let itemChangePromise = getCache("onItemChange");
			let rowID = await waitForRegister(option);
			let result = await itemChangePromise;

			let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			let valueElem = rowElem.querySelector(".value");

			// Should be enabled and editable
			assert.isTrue(result.editable);
			assert.isFalse(valueElem.readOnly);
			assert.isTrue(result.enabled);
			assert.isFalse(rowElem.hidden);

			initCache("onItemChange");
			itemChangePromise = getCache("onItemChange");
			let docItem = new Zotero.Item('document');
			await docItem.saveTx();
			await ZoteroPane.selectItem(docItem.id);
			result = await itemChangePromise;

			// Should be enabled and not editable
			assert.isFalse(result.editable);
			assert.isTrue(valueElem.readOnly);
			assert.isTrue(result.enabled);
			assert.isFalse(rowElem.hidden);

			let file = getTestDataDirectory();
			file.append('test.pdf');
			let attachment = await Zotero.Attachments.importFromFile({
				file,
				parentItemID: docItem.id
			});

			initCache("onItemChange");
			itemChangePromise = getCache("onItemChange");

			await ZoteroPane.viewItems([attachment]);
			let tabID = Zotero_Tabs.selectedID;
			let reader = Zotero.Reader.getByTabID(tabID);
			await reader._initPromise;
			// Ensure context pane is open
			ZoteroContextPane.collapsed = false;
			result = await itemChangePromise;

			let itemDetails = ZoteroContextPane.context._getItemContext(tabID);
			rowElem = itemDetails.getPane("info").querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			valueElem = rowElem.querySelector(".value");

			// Should not be enabled in non-library tab
			assert.isFalse(result.enabled);
			assert.isTrue(rowElem.hidden);

			await waitForUnregister(rowID);
		});

		it("should render row at position", async function () {
			let startOption = Object.assign({}, defaultOption, {
				position: "start",
			});
			let afterCreatorsOption = Object.assign({}, defaultOption, {
				position: "afterCreators",
			});
			let endOption = Object.assign({}, defaultOption, {
				position: "end",
			});

			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);

			// Row at start
			let rowID = await waitForRegister(startOption);
			let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);

			assert.notExists(rowElem.previousElementSibling);
			await waitForUnregister(rowID);

			// Row after creator rows
			rowID = await waitForRegister(afterCreatorsOption);
			rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);

			assert.exists(rowElem.previousElementSibling.querySelector(".creator-type-value"));
			assert.notExists(rowElem.nextElementSibling.querySelector(".creator-type-value"));
			await waitForUnregister(rowID);

			// Row at end
			rowID = rowID = await waitForRegister(endOption);
			rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);

			assert.exists(rowElem.nextElementSibling.querySelector("*[fieldname=dateAdded]"));
			await waitForUnregister(rowID);
		});

		it("should set input editable", async function () {
			let editableOption = Object.assign({}, defaultOption, {
				editable: true,
			});
			let notEditableOption = Object.assign({}, defaultOption, {
				editable: false,
			});

			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);

			let rowID = await waitForRegister(defaultOption);

			let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			let valueElem = rowElem.querySelector(".value");

			assert.isFalse(valueElem.readOnly);

			await waitForUnregister(rowID);

			rowID = await waitForRegister(editableOption);

			rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			valueElem = rowElem.querySelector(".value");

			assert.isFalse(valueElem.readOnly);

			await waitForUnregister(rowID);

			rowID = await waitForRegister(notEditableOption);

			rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			valueElem = rowElem.querySelector(".value");

			assert.isTrue(valueElem.readOnly);

			await waitForUnregister(rowID);
		});

		it("should set input multiline", async function () {
			let multilineOption = Object.assign({}, defaultOption, {
				multiline: true,
			});
			let notMultilineOption = Object.assign({}, defaultOption, {
				multiline: false,
			});

			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);
			
			let rowID = await waitForRegister(defaultOption);

			let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			let valueElem = rowElem.querySelector(".value");

			assert.isFalse(valueElem.multiline);

			await waitForUnregister(rowID);

			rowID = await waitForRegister(multilineOption);

			rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			valueElem = rowElem.querySelector(".value");

			assert.isTrue(valueElem.multiline);

			await waitForUnregister(rowID);

			rowID = await waitForRegister(notMultilineOption);

			rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			valueElem = rowElem.querySelector(".value");

			assert.isFalse(valueElem.multiline);

			await waitForUnregister(rowID);
		});

		it("should set input nowrap", async function () {
			let noWrapOption = Object.assign({}, defaultOption, {
				nowrap: true,
			});
			let wrapOption = Object.assign({}, defaultOption, {
				nowrap: false,
			});

			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);
			
			let rowID = await waitForRegister(defaultOption);

			let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			let valueElem = rowElem.querySelector(".value");

			assert.isFalse(valueElem.noWrap);

			await waitForUnregister(rowID);

			rowID = await waitForRegister(noWrapOption);

			rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			valueElem = rowElem.querySelector(".value");

			assert.isTrue(valueElem.noWrap);

			await waitForUnregister(rowID);

			rowID = await waitForRegister(wrapOption);

			rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			valueElem = rowElem.querySelector(".value");

			assert.isFalse(valueElem.noWrap);

			await waitForUnregister(rowID);
		});

		it("should refresh custom row value", async function () {
			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);
			
			let rowID = await waitForRegister(defaultOption);

			let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			let valueElem = rowElem.querySelector(".value");

			let oldValue = valueElem.value;

			// Since this row does not have `onSetData`, changing value does not do anything
			// We just want to test if the value can be refreshed by calling `updateInfoRow`
			let newValue = "TEST CUSTOM ROW EDITED";
			valueElem.value = newValue;

			let notifyPromise = waitForNotifierEvent("refresh", "infobox");

			// Manually refresh the row
			Zotero.ItemPaneManager.refreshInfoRow(rowID);
			await notifyPromise;

			assert.equal(oldValue, valueElem.value);

			await waitForUnregister(rowID);
		});

		it("should render custom row value after item change", async function () {
			// https://github.com/zotero/zotero/issues/4874

			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);
			
			let rowID = await waitForRegister(defaultOption);

			let rowElem = infoSection.querySelector(`[data-custom-row-id="${CSS.escape(rowID)}"]`);
			let valueElem = rowElem.querySelector(".value");

			let value = valueElem.value;
			assert.equal(`${item.id}`, value);

			initCache("onGetData");
			let getDataPromise = getCache("onGetData");

			let docItem = new Zotero.Item('document');
			await docItem.saveTx();
			await ZoteroPane.selectItem(docItem.id);
			await getDataPromise;

			value = valueElem.value;
			assert.equal(`${docItem.id}`, value);

			await waitForUnregister(rowID);
		});
	});

	describe("Item tree custom column", function () {
		// Only test hooks, as other column options are covered in item tree tests
		let defaultOption = {
			columnID: "default-test",
			pluginID: "zotero@zotero.org",
			dataKey: "api-test",
			label: "APITest",
			dataProvider: (item) => {
				let data = `${item.id}`;
				updateCache("dataProvider", data);
				return data;
			},
		};

		let waitForRegister = async (option) => {
			initCache("dataProvider");
			let getDataPromise = getCache("dataProvider");
			let columnKey = Zotero.ItemTreeManager.registerColumn(option);
			await getDataPromise;
			return columnKey;
		};

		let waitForColumnEnable = async (dataKey) => {
			_itemsView._columnPrefs[dataKey] = {
				dataKey,
				hidden: false,
			};
			let columns = _itemsView._getColumns();
			let columnID = columns.findIndex(column => column.dataKey === dataKey);
			if (columnID === -1) {
				return;
			}
			let column = columns[columnID];
			if (!column.hidden) {
				return;
			}
			_itemsView.tree._columns.toggleHidden(columnID);

			// Wait for column header to render
			await waitForCallback(
				() => !!doc.querySelector(`#zotero-items-tree .virtualized-table-header .cell.${CSS.escape(dataKey)}`),
				100, 3);
		};

		let waitForUnregister = async (columnID) => {
			let unregisterPromise = waitForNotifierEvent("refresh", "itemtree");
			let success = Zotero.ItemTreeManager.unregisterColumn(columnID);
			await unregisterPromise;
			return success;
		};

		let getSelectedRowCell = (dataKey) => {
			let cell = doc.querySelector(`#zotero-items-tree .row.selected .${CSS.escape(dataKey)}`);
			return cell;
		};

		beforeEach(async function () {
			resetCaches();
		});

		afterEach(function () {
			Zotero_Tabs.select("zotero-pane");
			Zotero_Tabs.closeAll();
		});

		it("should render custom column and call dataProvider hook", async function () {
			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);
			
			let columnKey = await waitForRegister(defaultOption);

			await waitForColumnEnable(columnKey);

			// Should render custom column cell
			let cellElem = getSelectedRowCell(columnKey);

			assert.exists(cellElem);

			// Should call dataProvider and render the value
			assert.equal(`${item.id}`, cellElem.textContent);

			await waitForUnregister(columnKey);
		});

		it("should use custom renderCell hook", async function () {
			let customCellContent = "Custom renderCell";

			let option = Object.assign({}, defaultOption, {
				renderCell: (index, data, column, isFirstColumn, doc) => {
					// index: the index of the row
					// data: the data to display in the column, return of `dataProvider`
					// column: the column options
					// isFirstColumn: true if this is the first column
					// doc: the document of the item tree
					// return: the HTML to display in the cell
					const cell = doc.createElement('span');
					cell.className = `cell ${column.className}`;
					cell.textContent = customCellContent;
					cell.style.color = 'red';
					updateCache("renderCell", cell.textContent);
					return cell;
				},
			});

			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);

			let columnKey = await waitForRegister(option);

			await waitForColumnEnable(columnKey);

			// Should render custom column cell
			let cellElem = getSelectedRowCell(columnKey);

			assert.exists(cellElem);

			// Should call renderCell and render the value
			assert.equal('rgb(255, 0, 0)', win.getComputedStyle(cellElem).color);

			await waitForUnregister(columnKey);
		});

		it("should not break ui when hooks throw error", async function () {
			let option = Object.assign({}, defaultOption, {
				dataProvider: () => {
					updateCache("dataProvider", "Test error");
					throw new Error("Test error");
				},
				renderCell: () => {
					updateCache("renderCell", "Test error");
					throw new Error("Test error");
				}
			});

			let item = new Zotero.Item('book');
			await item.saveTx();
			await ZoteroPane.selectItem(item.id);
			
			let columnKey = await waitForRegister(option);

			await waitForColumnEnable(columnKey);

			// Should not break ui
			let columnElem = getSelectedRowCell(columnKey);
			assert.exists(columnElem);

			await waitForUnregister(columnKey);
		});
	});

	describe("Custom menu", function () {
		let defaultOption = {
			menuID: "default-test",
			pluginID: "zotero@zotero.org",
			menus: [{
				menuType: "menuitem",
				l10nID: "menu-print",
				onShowing: (event, context) => {
					updateCache("onShowing", context);
				},
				onCommand: (event, context) => {
					updateCache("onCommand", context);
				}
			}]
		};

		let defaultContextKeys = [
			"menuElem",
			"setEnabled",
			"setIcon",
			"setL10nArgs",
			"setVisible",
			"tabID",
			"tabType",
			"tabSubType",
		];

		// From https://searchfox.org/mozilla-esr128/source/browser/base/content/test/menubar/browser_file_close_tabs.js
		async function simulateMenuOpen(menu) {
			return new Promise((resolve) => {
				menu.addEventListener("popupshown", resolve, { once: true });
				menu.dispatchEvent(new MouseEvent("popupshowing"));
				menu.dispatchEvent(new MouseEvent("popupshown"));
			});
		}

		async function simulateMenuClosed(menu) {
			return new Promise((resolve) => {
				menu.addEventListener("popuphidden", resolve, { once: true });
				menu.dispatchEvent(new MouseEvent("popuphiding"));
				menu.dispatchEvent(new MouseEvent("popuphidden"));
			});
		}

		async function simulateMenuItemClick(menuitem) {
			return new Promise((resolve) => {
				menuitem.addEventListener("command", resolve, { once: true });
				menuitem.dispatchEvent(new MouseEvent("click"));
				menuitem.dispatchEvent(new MouseEvent("command"));
			});
		}

		async function checkMenuContext(option, popupSelector, contextKeys, targetDoc) {
			if (!targetDoc) {
				targetDoc = doc;
			}

			option = Object.assign({}, defaultOption, option);
			let target = option.target;

			initCache("onShowing");
			let menuID = Zotero.MenuManager.registerMenu(option);
			assert.isString(menuID);

			let popup;
			if (typeof popupSelector === "function") {
				popup = await popupSelector();
			}
			else {
				popup = targetDoc.querySelector(popupSelector);
				await simulateMenuOpen(popup);
			}
			assert.exists(popup, `Popup for target ${target} should exist`);

			let context = await getCache("onShowing");

			if (contextKeys) {
				for (let key of contextKeys) {
					assert.property(context, key, `Context of ${target} should have key: ${key}`);
				}
			}

			if (typeof popupSelector === "function") {
				popup.hidePopup();
			}
			else {
				await simulateMenuClosed(popup);
			}

			assert.isTrue(Zotero.MenuManager.unregisterMenu(menuID));
		}

		beforeEach(async function () {
			resetCaches();
		});

		afterEach(function () {
			Zotero_Tabs.select("zotero-pane");
			Zotero_Tabs.closeAll();
		});

		it("should register custom menu and call hooks", async function () {
			let option = Object.assign({}, defaultOption, {
				target: "main/menubar/file",
				menus: [{
					menuType: "menuitem",
					l10nID: "menu-print",
					onShowing: (event, context) => {
						updateCache("onShowing", context);
					},
					onShown: (event, context) => {
						updateCache("onShown", context);
					},
					onHiding: (event, context) => {
						updateCache("onHiding", context);
					},
					onHidden: (event, context) => {
						updateCache("onHidden", context);
					},
					onCommand: (event, context) => {
						updateCache("onCommand", context);
					}
				}]
			});
			initCache("onShowing");
			initCache("onShown");
			initCache("onHiding");
			initCache("onHidden");
			initCache("onCommand");
			let menuID = Zotero.MenuManager.registerMenu(option);
			assert.isString(menuID);

			let popup = doc.querySelector("#menu_FilePopup");
			await simulateMenuOpen(popup);
			let context = await getCache("onShowing");
			await getCache("onShown");

			assert.exists(context.menuElem);
			assert.equal(context.menuElem.parentNode, popup);

			await simulateMenuItemClick(context.menuElem);
			context = await getCache("onCommand");
			assert.exists(context);

			await simulateMenuClosed(popup);
			await Promise.all([
				getCache("onHiding"),
				getCache("onHidden")
			]);

			// Unregister the menu
			assert.isTrue(Zotero.MenuManager.unregisterMenu(menuID));

			// After unregistering and re-opening the popup, the menu should not exist
			popup.addEventListener("popupshown", () => {
				assert.notExists(popup.querySelector('.zotero-custom-menu-item'));
			}, { once: true });

			// Since the event is manually dispatched, the above listener will be triggered before the promise resolves
			await simulateMenuOpen(popup);
		});

		it("should hide custom menu when not enabled for tab type", async function () {
			let option = Object.assign({}, defaultOption, {
				target: "main/menubar/file",
				menus: [
					{
						menuType: "menuitem",
						l10nID: "menu-print",
						onShowing: (event, context) => {
							context.menuElem?.classList.add("test-menu-library");
						},
						enableForTabTypes: ["library"]
					},
					{
						menuType: "menuitem",
						l10nID: "menu-print",
						onShowing: (event, context) => {
							context.menuElem?.classList.add("test-menu-pdf");
						},
						enableForTabTypes: ["reader/pdf"]
					},
					{
						menuType: "menuitem",
						l10nID: "menu-print",
						onShowing: (event, context) => {
							context.menuElem?.classList.add("test-menu-epub");
						},
						enableForTabTypes: ["reader/epub"]
					},
					{
						menuType: "menuitem",
						l10nID: "menu-print",
						onShowing: (event, context) => {
							context.menuElem?.classList.add("test-menu-snapshot");
						},
						enableForTabTypes: ["reader/snapshot"]
					},
					{
						menuType: "menuitem",
						l10nID: "menu-print",
						onShowing: (event, context) => {
							context.menuElem?.classList.add("test-menu-anyReader");
						},
						enableForTabTypes: ["reader/*"]
					}
				]
			});

			// Create items for different tab types
			let pdfItem = await importFileAttachment('test.pdf');
			let epubItem = await importFileAttachment('stub.epub');
			let snapshotItem = await importFileAttachment('test.html');

			let menuID = Zotero.MenuManager.registerMenu(option);
			assert.isString(menuID);
			let popup = doc.querySelector("#menu_FilePopup");

			function assertMenuVisibility(popup, visibilities) {
				for (let [className, visible] of Object.entries(visibilities)) {
					let menuItem = popup.querySelector(`.test-menu-${className}`);
					if (visible) {
						assert.isFalse(menuItem.hidden, `Menu item ${className} should be visible`);
					}
					else {
						assert.isTrue(menuItem.hidden, `Menu item ${className} should be hidden`);
					}
				}
			}

			// Open the menu in library tab
			await simulateMenuOpen(popup);
			// Should show library menu item
			assertMenuVisibility(popup, {
				library: true,
				pdf: false,
				epub: false,
				snapshot: false,
				anyReader: false
			});
			await simulateMenuClosed(popup);

			// Switch to PDF tab
			await ZoteroPane.viewItems([pdfItem]);
			let pdfTabID = Zotero_Tabs.selectedID;
			await Zotero.Reader.getByTabID(pdfTabID)._initPromise;
			await simulateMenuOpen(popup);
			// Should show PDF and anyReader menu items
			assertMenuVisibility(popup, {
				library: false,
				pdf: true,
				epub: false,
				snapshot: false,
				anyReader: true
			});
			await simulateMenuClosed(popup);

			// Switch to EPUB tab
			await ZoteroPane.viewItems([epubItem]);
			let epubTabID = Zotero_Tabs.selectedID;
			await Zotero.Reader.getByTabID(epubTabID)._initPromise;
			await simulateMenuOpen(popup);
			// Should show EPUB and anyReader menu items
			assertMenuVisibility(popup, {
				library: false,
				pdf: false,
				epub: true,
				snapshot: false,
				anyReader: true
			});
			await simulateMenuClosed(popup);

			// Switch to Snapshot tab
			await ZoteroPane.viewItems([snapshotItem]);
			let snapshotTabID = Zotero_Tabs.selectedID;
			await Zotero.Reader.getByTabID(snapshotTabID)._initPromise;
			await simulateMenuOpen(popup);
			// Should show Snapshot and anyReader menu items
			assertMenuVisibility(popup, {
				library: false,
				pdf: false,
				epub: false,
				snapshot: true,
				anyReader: true
			});
			await simulateMenuClosed(popup);

			// Unregister the menu
			assert.isTrue(Zotero.MenuManager.unregisterMenu(menuID));
		});

		it("should register recursive submenu", async function () {
			let testMenuClass = "test-recursive-submenu";
			let option = Object.assign({}, defaultOption, {
				target: "main/menubar/file",
				menus: [{
					menuType: "submenu",
					l10nID: "menu-print",
					onShowing: (event, context) => {
						context.menuElem?.classList.add(testMenuClass);
					},
					menus: [{
						menuType: "menuitem",
						l10nID: "menu-print",
						onShowing: (event, context) => {
							updateCache("onShowing", context);
						},
						onCommand: (event, context) => {
							updateCache("onCommand", context);
						}
					}]
				}]
			});

			initCache("onCommand");
			initCache("onShowing");

			let menuID = Zotero.MenuManager.registerMenu(option);
			assert.isString(menuID);

			let popup = doc.querySelector("#menu_FilePopup");

			await simulateMenuOpen(popup);

			// The onShowing hook of child menu should not be called yet
			assert.isEmpty(caches.onShowing.result, "Child menu onShowing should not be called yet");

			let submenu = popup.querySelector(`.${testMenuClass}`);
			assert.exists(submenu, "Submenu should be created");

			let submenuPopup = submenu.querySelector("menupopup");
			await simulateMenuOpen(submenuPopup);
			let context = await getCache("onShowing");
			assert.exists(context.menuElem, "Submenu onShowing context should have menuElem");

			await simulateMenuItemClick(context.menuElem);
			context = await getCache("onCommand");
			assert.exists(context, "Submenu command context should exist");

			assert.isTrue(Zotero.MenuManager.unregisterMenu(menuID));
		});

		it("should register main window menu", async function () {
			let menuTargetMap = {
				"main/menubar/file": "#menu_FilePopup",
				"main/menubar/edit": "#menu_EditPopup",
				"main/menubar/view": "#menu_viewPopup",
				"main/menubar/go": "#menu_goPopup",
				"main/menubar/tools": "#menu_ToolsPopup",
				"main/menubar/help": "#menu_HelpPopup",
			};
			
			let contextKeys = [
				...defaultContextKeys,
				"items",
			];

			for (let [target, popupSelector] of Object.entries(menuTargetMap)) {
				await checkMenuContext({ target }, popupSelector, contextKeys);
			}
		});

		it("should register library menu", async function () {
			let menuTargetMap = {
				"main/library/item": {
					popupSelector: () => {
						ZoteroPane.onItemsContextMenuOpen(new MouseEvent("contextmenu"), 0, 0);
						return doc.querySelector("#zotero-itemmenu");
					},
					contextKeys: [
						...defaultContextKeys,
						"items",
						"collectionTreeRow",
					]
				},
				"main/library/collection": {
					popupSelector: () => {
						ZoteroPane.onCollectionsContextMenuOpen(new MouseEvent("contextmenu"), 0, 0);
						return doc.querySelector("#zotero-collectionmenu");
					},
					contextKeys: [
						...defaultContextKeys,
						"collectionTreeRow",
					]
				},
				"main/library/addAttachment": {
					popupSelector: "#menu_attachmentAdd > menupopup",
					contextKeys: [
						...defaultContextKeys,
						"items",
					]
				},
				"main/library/addNote": {
					popupSelector: "#menu_noteAdd > menupopup",
					contextKeys: [
						...defaultContextKeys,
						"items",
					]
				}
			};

			for (let [target, { popupSelector, contextKeys }] of Object.entries(menuTargetMap)) {
				await checkMenuContext({ target }, popupSelector, contextKeys);
			}
		});

		it("should register other main window menus", async function () {
			let menuTargetMap = {
				"main/tab": {
					popupSelector: async () => {
						let tabID = Zotero_Tabs.selectedID;
						return Zotero_Tabs._openMenu(0, 0, tabID);
					},
					contextKeys: [
						...defaultContextKeys,
						"items"
					]
				},
				"itemPane/info/row": {
					popupSelector: async () => {
						let item = new Zotero.Item('book');
						await item.saveTx();
						await ZoteroPane.selectItem(item.id);
						let itemDetails = ZoteroPane.itemPane._itemDetails;
						let infoBox = itemDetails.getPane("info");
						let row = infoBox.querySelector("#itembox-field-value-title");
						row.focus();
						let event = new MouseEvent("contextmenu", {
							bubbles: true,
							cancelable: true,
						});
						row.dispatchEvent(event);
						let popup = infoBox.querySelector("#zotero-field-menu");
						return popup;
					},
					contextKeys: [
						...defaultContextKeys,
						"items",
						"editable",
						"fieldName",
						"targetElem"
					]
				},
				"sidenav/locate": {
					popupSelector: async () => {
						let item = new Zotero.Item('book');
						await item.saveTx();
						await ZoteroPane.selectItem(item.id);
						let sidenav = ZoteroPane.itemPane._sidenav;
						let button = sidenav.querySelector("toolbarbutton[data-action='locate']");
						let event = new MouseEvent("mousedown", {
							bubbles: true,
							cancelable: true,
						});
						button.dispatchEvent(event);
						let popup = button.querySelector("menupopup");
						return popup;
					},

					contextKeys: [
						...defaultContextKeys,
						"items",
					]
				}
			};

			for (let [target, { popupSelector, contextKeys }] of Object.entries(menuTargetMap)) {
				await checkMenuContext({ target }, popupSelector, contextKeys);
			}
		});

		it("should register note context menu", async function () {
			let menuTargetMap = {
				"notesPane/addItemNote": async () => {
					let button = notesList.querySelector(".item-notes .section-custom-button.add");
					let event = new MouseEvent("command", {
						bubbles: true,
						cancelable: true,
					});
					button.dispatchEvent(event);
					let popup = notesContext.querySelector(".context-pane-add-child-note-button-popup");
					return popup;
				},
				"notesPane/addStandaloneNote": async () => {
					let button = notesList.querySelector(".all-notes .section-custom-button.add");
					let event = new MouseEvent("command", {
						bubbles: true,
						cancelable: true,
					});
					button.dispatchEvent(event);
					let popup = notesContext.querySelector(".context-pane-add-standalone-note-button-popup");
					return popup;
				}
			};
			
			let item = new Zotero.Item('book');
			await item.saveTx();
			let pdfItem = await importFileAttachment('test.pdf', {
				parentItemID: item.id
			});
			await ZoteroPane.viewItems([pdfItem]);
			await Zotero.Reader.getByTabID(Zotero_Tabs.selectedID)._initPromise;
			ZoteroContextPane.collapsed = false;
			ZoteroContextPane.sidenav._contextNotesPaneVisible = true;
			let contextPane = ZoteroContextPane.context;
			let notesContext = contextPane._getCurrentNotesContext();
			let notesList = notesContext.notesList;

			for (let [target, popupSelector] of Object.entries(menuTargetMap)) {
				await checkMenuContext({ target }, popupSelector);
			}
		});

		it("should register reader window menu", async function () {
			let mainMenubarTargetMap = {
				"reader/menubar/file": "#menu_FilePopup",
				"reader/menubar/edit": "#menu_EditPopup",
				"reader/menubar/view": "#menu_viewPopup",
				"reader/menubar/go": "#menu_goPopup",
				"reader/menubar/window": "#windowPopup",
			};

			let pdfAttachment = await importFileAttachment('test.pdf');
			let reader = await Zotero.Reader.open(pdfAttachment.id, undefined, {
				openInWindow: true,
			});
			await reader._initPromise;
			
			let readerWin = reader._window;
			let readerDoc = readerWin.document;

			for (let [target, popupSelector] of Object.entries(mainMenubarTargetMap)) {
				await checkMenuContext({ target }, popupSelector, undefined, readerDoc);
			}

			reader.close();
		});

		it("should remove menu DOM elements on plugin shutdown", async function () {
			let pluginID = "test-plugin@zotero.org";
			let option = Object.assign({}, defaultOption, {
				pluginID,
				target: "main/menubar/file",
			});

			initCache("onShowing");
			let menuID = Zotero.MenuManager.registerMenu(option);
			assert.isString(menuID);

			let popup = doc.querySelector("#menu_FilePopup");
			await simulateMenuOpen(popup);
			await getCache("onShowing");

			let menuSelector = `.zotero-custom-menu-item.${CSS.escape(menuID)}`;
			assert.exists(popup.querySelector(menuSelector),
				"Menu element should exist after registration");

			await simulateMenuClosed(popup);

			await Zotero.MenuManager._menuManager._unregisterByPluginID(pluginID);

			assert.notExists(popup.querySelector(menuSelector),
				"Menu element should be removed after plugin shutdown");
		});

		it("should group custom menus", async function () {
			let option = Object.assign({}, defaultOption, {
				target: "main/library/item",
				menus: Array.from({length: 100}, () => ({
					menuType: "menuitem",
					l10nID: "menu-print",
				})),
			});

			initCache("onShowing");
			initCache("onCommand");

			let menuID = Zotero.MenuManager.registerMenu(option);
			assert.isString(menuID);

			let popup = doc.querySelector("#zotero-itemmenu");

			let promise = new Promise((resolve) => {
				popup.addEventListener("popupshown", resolve, { once: true });
			});

			ZoteroPane.onItemsContextMenuOpen(new MouseEvent("contextmenu"), 0, 0);
			await promise;

			let groupedMenus = popup.querySelector(".zotero-custom-menu-group-submenu");
			assert.exists(groupedMenus, "Grouped menus should be created");

			assert.isTrue(Zotero.MenuManager.unregisterMenu(menuID));
		});
	});
});
