zotero/test/tests/pluginAPITest.js
Dan Stillman dc6d55a137 Remove collectionTreeRow from the plugin menu context
Menu plugins reading it acted on one arbitrary row of the selection.
Reading it now throws and names collectionTreeRows, which the context
has already provided since multi-collection selection landed.

The context now copies property descriptors rather than values, since
copying values would evaluate the throwing collectionTreeRow getter for
every menu.
2026-07-29 13:22:58 -04:00

1169 lines
33 KiB
JavaScript

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));
});
});
});