// Useful "constants" var sqlDateTimeRe = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; var isoDateTimeRe = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; var zoteroObjectKeyRe = /^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/; // based on Zotero.Utilities::generateObjectKey() /** * Waits for a DOM event on the specified node. Returns a promise * resolved with the event. */ function waitForDOMEvent(target, event, capture) { var deferred = Zotero.Promise.defer(); var func = function(ev) { target.removeEventListener("event", func, capture); deferred.resolve(ev); } target.addEventListener(event, func, capture); return deferred.promise; } /** * Open a chrome window and return a promise for the window * * @return {Promise} */ function loadWindow(winurl, argument) { var win = window.openDialog(winurl, "_blank", "chrome", argument); return waitForDOMEvent(win, "load").then(function() { return win; }); } /** * Open a browser window and return a promise for the window * * @return {Promise} */ function loadBrowserWindow() { var win = window.openDialog("chrome://browser/content/browser.xul", "", "all,height=400,width=1000"); return waitForDOMEvent(win, "load").then(function() { return win; }); } /** * Opens the Zotero pane and selects My Library. Returns the containing window. * * @param {Window} [win] - Existing window to use; if not specified, a new window is opened */ var loadZoteroPane = Zotero.Promise.coroutine(function* (win) { if (!win) { var win = yield loadBrowserWindow(); } Zotero.Prefs.clear('lastViewedFolder'); win.ZoteroOverlay.toggleDisplay(true); yield waitForItemsLoad(win, 0); return win; }); /** * Waits for a window with a specific URL to open. Returns a promise for the window, and * optionally passes the window to a callback immediately for use with modal dialogs, * which prevent async code from continuing */ function waitForWindow(uri, callback) { var deferred = Zotero.Promise.defer(); Components.utils.import("resource://gre/modules/Services.jsm"); var loadobserver = function(ev) { ev.originalTarget.removeEventListener("load", loadobserver, false); if(ev.target.location.href == uri) { Services.ww.unregisterNotification(winobserver); var win = ev.target.docShell .QueryInterface(Components.interfaces.nsIInterfaceRequestor) .getInterface(Components.interfaces.nsIDOMWindow); // Give window code time to run on load win.setTimeout(function () { if (callback) { try { // If callback is a promise, wait for it let maybePromise = callback(win); if (maybePromise && maybePromise.then) { maybePromise.then(() => deferred.resolve(win)).catch(e => deferred.reject(e)); return; } } catch (e) { Zotero.logError(e); win.close(); deferred.reject(e); return; } } deferred.resolve(win); }); } else { Zotero.debug(`Ignoring window ${uri} in waitForWindow()`); } }; var winobserver = {"observe":function(subject, topic, data) { if(topic != "domwindowopened") return; var win = subject.QueryInterface(Components.interfaces.nsIDOMWindow); win.addEventListener("load", loadobserver, false); }}; Services.ww.registerNotification(winobserver); return deferred.promise; } /** * Wait for an alert or confirmation dialog to pop up and then close it * * @param {Function} [onOpen] - Function that is passed the dialog once it is opened. * Can be used to make assertions on the dialog contents * (e.g., with dialog.document.documentElement.textContent) * @param {String} [button='accept'] - Button in dialog to press (e.g., 'cancel', 'extra1') * @return {Promise} */ function waitForDialog(onOpen, button='accept', url) { return waitForWindow(url || "chrome://global/content/commonDialog.xul", Zotero.Promise.method(function (dialog) { var failure = false; if (onOpen) { try { onOpen(dialog); } catch (e) { failure = e; } } if (button === false) { if (failure) { throw failure; } } else if (button != 'cancel') { let deferred = Zotero.Promise.defer(); function acceptWhenEnabled() { // Handle delayed buttons if (dialog.document.documentElement.getButton(button).disabled) { dialog.setTimeout(function () { acceptWhenEnabled(); }, 250); } else { dialog.document.documentElement.getButton(button).click(); if (failure) { deferred.reject(failure); } else { deferred.resolve(); } } } acceptWhenEnabled(); return deferred.promise; } else { dialog.document.documentElement.getButton(button).click(); if (failure) { throw failure; } } })) } var selectLibrary = Zotero.Promise.coroutine(function* (win, libraryID) { libraryID = libraryID || Zotero.Libraries.userLibraryID; yield win.ZoteroPane.collectionsView.selectLibrary(libraryID); yield waitForItemsLoad(win); }); var waitForItemsLoad = Zotero.Promise.coroutine(function* (win, collectionRowToSelect) { var zp = win.ZoteroPane; var cv = zp.collectionsView; var deferred = Zotero.Promise.defer(); cv.addEventListener('load', () => deferred.resolve()); yield deferred.promise; if (collectionRowToSelect !== undefined) { yield cv.selectWait(collectionRowToSelect); } deferred = Zotero.Promise.defer(); zp.itemsView.addEventListener('load', () => deferred.resolve()); return deferred.promise; }); /** * Waits for a single item event. Returns a promise for the item ID(s). */ function waitForItemEvent(event) { return waitForNotifierEvent(event, 'item').then(x => x.ids); } /** * Wait for a single notifier event and return a promise for the data */ function waitForNotifierEvent(event, type) { if (!event) throw new Error("event not provided"); var deferred = Zotero.Promise.defer(); var notifierID = Zotero.Notifier.registerObserver({notify:function(ev, type, ids, extraData) { if(ev == event) { Zotero.Notifier.unregisterObserver(notifierID); deferred.resolve({ ids: ids, extraData: extraData }); } }}, [type]); return deferred.promise; } /** * Looks for windows with a specific URL. */ function getWindows(uri) { Components.utils.import("resource://gre/modules/Services.jsm"); var enumerator = Services.wm.getEnumerator(null); var wins = []; while(enumerator.hasMoreElements()) { var win = enumerator.getNext(); if(win.location == uri) { wins.push(win); } } return wins; } /** * Resolve a promise when a specified callback returns true. interval * specifies the interval between checks. timeout specifies when we * should assume failure. */ function waitForCallback(cb, interval, timeout) { var deferred = Zotero.Promise.defer(); if(interval === undefined) interval = 100; if(timeout === undefined) timeout = 10000; var start = Date.now(); var id = setInterval(function() { var success = cb(); if(success) { clearInterval(id); deferred.resolve(success); } else if(Date.now() - start > timeout*1000) { clearInterval(id); deferred.reject(new Error("Promise timed out")); } }, interval); return deferred.promise; } function clickOnItemsRow(itemsView, row, button = 0) { var x = {}; var y = {}; var width = {}; var height = {}; itemsView._treebox.getCoordsForCellItem( row, itemsView._treebox.columns.getNamedColumn('zotero-items-column-title'), 'text', x, y, width, height ); // Select row to trigger multi-select var tree = itemsView._treebox.treeBody; var rect = tree.getBoundingClientRect(); var x = rect.left + x.value; var y = rect.top + y.value; tree.dispatchEvent(new MouseEvent("mousedown", { clientX: x, clientY: y, button, detail: 1 })); } /** * Synchronous inflate */ function gunzip(gzdata) { return pako.inflate(gzdata, { to: 'string' }); } /** * Get a default group used by all tests that want one, creating one if necessary */ var _defaultGroup; var getGroup = Zotero.Promise.method(function () { // Cleared in resetDB() if (_defaultGroup) { return _defaultGroup; } return _defaultGroup = createGroup({ name: "My Group" }); }); var createGroup = Zotero.Promise.coroutine(function* (props = {}) { var group = new Zotero.Group; group.id = props.id || Zotero.Utilities.rand(10000, 1000000); group.name = props.name || "Test " + Zotero.Utilities.randomString(); group.description = props.description || ""; group.editable = props.editable === undefined ? true : props.editable; group.filesEditable = props.filesEditable === undefined ? true : props.filesEditable; group.version = props.version === undefined ? Zotero.Utilities.rand(1000, 10000) : props.version; yield group.saveTx(); return group; }); var createFeed = Zotero.Promise.coroutine(function* (props = {}) { var feed = new Zotero.Feed; feed.name = props.name || "Test " + Zotero.Utilities.randomString(); feed.description = props.description || ""; feed.url = props.url || 'http://www.' + Zotero.Utilities.randomString() + '.com/feed.rss'; feed.refreshInterval = props.refreshInterval || 12; feed.cleanupAfter = props.cleanupAfter || 2; yield feed.saveTx(); return feed; }); var clearFeeds = Zotero.Promise.coroutine(function* () { let feeds = Zotero.Feeds.getAll(); for (let i=0; i e); } /** * Ensures that the PDF tools are installed, or installs them if not. * * @return {Promise} */ var installPDFTools = Zotero.Promise.coroutine(function* () { if(Zotero.Fulltext.pdfConverterIsRegistered() && Zotero.Fulltext.pdfInfoIsRegistered()) { return; } var version = yield Zotero.Fulltext.getLatestPDFToolsVersion(); yield Zotero.Fulltext.downloadPDFTool('info', version); yield Zotero.Fulltext.downloadPDFTool('converter', version); }); /** * @return {Promise} */ function uninstallPDFTools() { return Zotero.Fulltext.uninstallPDFTools(); } /** * Returns the nsIFile corresponding to the test data directory * (i.e., test/tests/data) */ function getTestDataDirectory() { Components.utils.import("resource://gre/modules/Services.jsm"); var resource = Services.io.getProtocolHandler("resource"). QueryInterface(Components.interfaces.nsIResProtocolHandler), resURI = Services.io.newURI("resource://zotero-unit-tests/data", null, null); return Services.io.newURI(resource.resolveURI(resURI), null, null). QueryInterface(Components.interfaces.nsIFileURL).file; } function getTestDataUrl(path) { path = path.split('/'); if (path[0].length == 0) { path.splice(0, 1); } return "resource://zotero-unit-tests/data/" + path.join('/'); } /** * Returns an absolute path to an empty temporary directory * (i.e., test/tests/data) */ var getTempDirectory = Zotero.Promise.coroutine(function* getTempDirectory() { Components.utils.import("resource://gre/modules/osfile.jsm"); let path, attempts = 3, zoteroTmpDirPath = Zotero.getTempDirectory().path; while (attempts--) { path = OS.Path.join(zoteroTmpDirPath, Zotero.Utilities.randomString()); try { yield OS.File.makeDir(path, { ignoreExisting: false }); break; } catch (e) { if (!attempts) throw e; // Throw on last attempt } } return path; }); /** * Resets the Zotero DB and restarts Zotero. Returns a promise resolved * when this finishes. * * @param {Object} [options] - Initialization options, as passed to Zotero.init(), overriding * any that were set at startup */ function resetDB(options = {}) { Zotero.Prefs.clear('lastViewedFolder') if (options.thisArg) { options.thisArg.timeout(60000); } var db = Zotero.getZoteroDatabase(); return Zotero.reinit(function() { db.remove(false); _defaultGroup = null; }, false, options).then(function() { return Zotero.Schema.schemaUpdatePromise; }); } /** * Equivalent to JSON.stringify, except that object properties are stringified * in a sorted order. */ function stableStringify(obj) { return JSON.stringify(obj, function(k, v) { if (v && typeof v == "object" && !Array.isArray(v)) { let o = {}, keys = Object.keys(v).sort(); for (let i = 0; i < keys.length; i++) { o[keys[i]] = v[keys[i]]; } return o; } return v; }, "\t"); } /** * Loads specified sample data from file */ function loadSampleData(dataName) { let data = Zotero.File.getContentsFromURL('resource://zotero-unit-tests/data/' + dataName + '.js'); return JSON.parse(data); } /** * Generates sample item data that is stored in data/sampleItemData.js */ function generateAllTypesAndFieldsData() { let data = {}; let itemTypes = Zotero.ItemTypes.getTypes(); // For most fields, use the field name as the value, but this doesn't // work well for some fields that expect values in certain formats let specialValues = { date: '1999-12-31', filingDate: '2000-01-02', accessDate: '1997-06-13T23:59:58Z', number: 3, numPages: 4, issue: 5, volume: 6, numberOfVolumes: 7, edition: 8, seriesNumber: 9, ISBN: '978-1-234-56789-7', ISSN: '1234-5679', url: 'http://www.example.com', pages: '1-10', DOI: '10.1234/example.doi', runningTime: '1:22:33', language: 'en-US' }; // Item types that should not be included in sample data let excludeItemTypes = ['note', 'attachment']; for (let i = 0; i < itemTypes.length; i++) { if (excludeItemTypes.indexOf(itemTypes[i].name) != -1) continue; let itemFields = data[itemTypes[i].name] = { itemType: itemTypes[i].name }; let fields = Zotero.ItemFields.getItemTypeFields(itemTypes[i].id); for (let j = 0; j < fields.length; j++) { let field = fields[j]; field = Zotero.ItemFields.getBaseIDFromTypeAndField(itemTypes[i].id, field) || field; let name = Zotero.ItemFields.getName(field), value; // Use field name as field value if (specialValues[name]) { value = specialValues[name]; } else { value = name.charAt(0).toUpperCase() + name.substr(1); // Make it look nice (sentence case) value = value.replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/ [A-Z](?![A-Z])/g, m => m.toLowerCase()); // not all-caps words } itemFields[name] = value; } let creatorTypes = Zotero.CreatorTypes.getTypesForItemType(itemTypes[i].id), creators = itemFields.creators = []; for (let j = 0; j < creatorTypes.length; j++) { let typeName = creatorTypes[j].name; creators.push({ creatorType: typeName, firstName: typeName + 'First', lastName: typeName + 'Last' }); } // Also add a single-field mode author, which is valid for all types let primaryCreatorType = Zotero.CreatorTypes.getName( Zotero.CreatorTypes.getPrimaryIDForType(itemTypes[i].id) ); creators.push({ creatorType: primaryCreatorType, lastName: 'Institutional Author', fieldMode: 1 }); } return data; } /** * Populates the database with sample items * The field values should be in the form exactly as they would appear in Zotero */ function populateDBWithSampleData(data) { return Zotero.DB.executeTransaction(function* () { for (let itemName in data) { let item = data[itemName]; let zItem = new Zotero.Item; zItem.fromJSON(item); item.id = yield zItem.save(); } return data; }); } var generateItemJSONData = Zotero.Promise.coroutine(function* generateItemJSONData(options, currentData) { let items = yield populateDBWithSampleData(loadSampleData('allTypesAndFields')), jsonData = {}; for (let itemName in items) { let zItem = yield Zotero.Items.getAsync(items[itemName].id); jsonData[itemName] = zItem.toJSON(options); // Don't replace some fields that _always_ change (e.g. item keys) // as long as it follows expected format // This makes it easier to generate more meaningful diffs if (!currentData || !currentData[itemName]) continue; for (let field in jsonData[itemName]) { let oldVal = currentData[itemName][field]; if (!oldVal) continue; let val = jsonData[itemName][field]; switch (field) { case 'dateAdded': case 'dateModified': if (!isoDateTimeRe.test(oldVal) || !isoDateTimeRe.test(val)) continue; break; case 'key': if (!zoteroObjectKeyRe.test(oldVal) || !zoteroObjectKeyRe.test(val)) continue; break; default: continue; } jsonData[itemName][field] = oldVal; } } return jsonData; }); var generateCiteProcJSExportData = Zotero.Promise.coroutine(function* generateCiteProcJSExportData(currentData) { let items = yield populateDBWithSampleData(loadSampleData('allTypesAndFields')), cslExportData = {}; for (let itemName in items) { let zItem = yield Zotero.Items.getAsync(items[itemName].id); cslExportData[itemName] = Zotero.Cite.System.prototype.retrieveItem(zItem); if (!currentData || !currentData[itemName]) continue; // Don't replace id as long as it follows expected format if (Number.isInteger(currentData[itemName].id) && Number.isInteger(cslExportData[itemName].id) ) { cslExportData[itemName].id = currentData[itemName].id; } } return cslExportData; }); var generateTranslatorExportData = Zotero.Promise.coroutine(function* generateTranslatorExportData(legacy, currentData) { let items = yield populateDBWithSampleData(loadSampleData('allTypesAndFields')), translatorExportData = {}; let itemGetter = new Zotero.Translate.ItemGetter(); itemGetter.legacy = !!legacy; for (let itemName in items) { let zItem = yield Zotero.Items.getAsync(items[itemName].id); itemGetter._itemsLeft = [zItem]; translatorExportData[itemName] = itemGetter.nextItem(); // Don't replace some fields that _always_ change (e.g. item keys) if (!currentData || !currentData[itemName]) continue; // For simplicity, be more lenient than for item key let uriRe = /^http:\/\/zotero\.org\/users\/local\/\w{8}\/items\/\w{8}$/; let itemIDRe = /^\d+$/; for (let field in translatorExportData[itemName]) { let oldVal = currentData[itemName][field]; if (!oldVal) continue; let val = translatorExportData[itemName][field]; switch (field) { case 'uri': if (!uriRe.test(oldVal) || !uriRe.test(val)) continue; break; case 'itemID': if (!itemIDRe.test(oldVal) || !itemIDRe.test(val)) continue; break; case 'key': if (!zoteroObjectKeyRe.test(oldVal) || !zoteroObjectKeyRe.test(val)) continue; break; case 'dateAdded': case 'dateModified': if (legacy) { if (!sqlDateTimeRe.test(oldVal) || !sqlDateTimeRe.test(val)) continue; } else { if (!isoDateTimeRe.test(oldVal) || !isoDateTimeRe.test(val)) continue; } break; default: continue; } translatorExportData[itemName][field] = oldVal; } } return translatorExportData; }); /** * Imports an attachment from a test file. * @param {string} filename - The filename to import (in data directory) * @return {Promise} */ function importFileAttachment(filename) { let testfile = getTestDataDirectory(); filename.split('/').forEach((part) => testfile.append(part)); return Zotero.Attachments.importFromFile({file: testfile}); } /** * Sets the fake XHR server to response to a given response * * @param {Object} server - Sinon FakeXMLHttpRequest server * @param {Object|String} response - Dot-separated path to predefined response in responses * object (e.g., keyInfo.fullAccess) or a JSON object * that defines the response * @param {Object} responses - Predefined responses */ function setHTTPResponse(server, baseURL, response, responses) { if (typeof response == 'string') { let [topic, key] = response.split('.'); if (!responses[topic]) { throw new Error("Invalid topic"); } if (!responses[topic][key]) { throw new Error("Invalid response key"); } response = responses[topic][key]; } var responseArray = [response.status !== undefined ? response.status : 200, {}, ""]; if (response.json) { responseArray[1]["Content-Type"] = "application/json"; responseArray[2] = JSON.stringify(response.json); } else { responseArray[1]["Content-Type"] = "text/plain"; responseArray[2] = response.text || ""; } if (!response.headers) { response.headers = {}; } response.headers["Fake-Server-Match"] = 1; for (let i in response.headers) { responseArray[1][i] = response.headers[i]; } server.respondWith(response.method, baseURL + response.url, responseArray); }