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", "collectionTreeRows", ] }, "main/library/collection": { popupSelector: () => { ZoteroPane.onCollectionsContextMenuOpen(new MouseEvent("contextmenu"), 0, 0); return doc.querySelector("#zotero-collectionmenu"); }, contextKeys: [ ...defaultContextKeys, "collectionTreeRows", ] }, "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)); }); }); });