diff --git a/.gitmodules b/.gitmodules
index 5000d9dc7..4eac6535f 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -7,3 +7,9 @@
[submodule "styles"]
path = styles
url = git://github.com/zotero/bundled-styles.git
+[submodule "test/resource/chai"]
+ path = test/resource/chai
+ url = https://github.com/chaijs/chai.git
+[submodule "test/resource/mocha"]
+ path = test/resource/mocha
+ url = https://github.com/mochajs/mocha.git
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 000000000..4e0365777
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,16 @@
+language: cpp
+compiler:
+ - gcc
+env:
+ matrix:
+ - FIREFOXVERSION="36.0.1"
+ - FIREFOXVERSION="31.5.0esr"
+notifications:
+ email: false
+before_install:
+ - export DISPLAY=:99.0
+ - sh -e /etc/init.d/xvfb start
+ - wget http://ftp.mozilla.org/pub/firefox/releases/${FIREFOXVERSION}/linux-x86_64/en-US/firefox-${FIREFOXVERSION}.tar.bz2
+ - tar -xjf firefox-${FIREFOXVERSION}.tar.bz2
+script:
+ - test/runtests.sh -x firefox/firefox
diff --git a/chrome/content/zotero/bindings/guidancepanel.xml b/chrome/content/zotero/bindings/guidancepanel.xml
index 41560f908..12f40106a 100644
--- a/chrome/content/zotero/bindings/guidancepanel.xml
+++ b/chrome/content/zotero/bindings/guidancepanel.xml
@@ -36,6 +36,7 @@
.
+
+ ***** END LICENSE BLOCK *****
+*/
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+function ZoteroUnit() {
+ this.wrappedJSObject = this;
+}
+ZoteroUnit.prototype = {
+ /* nsICommandLineHandler */
+ handle:function(cmdLine) {
+ this.tests = cmdLine.handleFlagWithParam("test", false);
+ this.noquit = cmdLine.handleFlag("noquit", false);
+ },
+
+ dump:function(x) {
+ dump(x);
+ },
+
+ contractID: "@mozilla.org/commandlinehandler/general-startup;1?type=zotero-unit",
+ classDescription: "Zotero Unit Command Line Handler",
+ classID: Components.ID("{b8570031-be5e-46e8-9785-38cd50a5d911}"),
+ service: true,
+ _xpcom_categories: [{category:"command-line-handler", entry:"m-zotero-unit"}],
+ QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsICommandLineHandler,
+ Components.interfaces.nsISupports])
+};
+
+
+var NSGetFactory = XPCOMUtils.generateNSGetFactory([ZoteroUnit]);
diff --git a/test/content/runtests.html b/test/content/runtests.html
new file mode 100644
index 000000000..85294a22d
--- /dev/null
+++ b/test/content/runtests.html
@@ -0,0 +1,14 @@
+
+
+
+ Zotero Unit Tests
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/content/runtests.js b/test/content/runtests.js
new file mode 100644
index 000000000..ae4adbd62
--- /dev/null
+++ b/test/content/runtests.js
@@ -0,0 +1,113 @@
+Components.utils.import("resource://gre/modules/FileUtils.jsm");
+Components.utils.import("resource://gre/modules/osfile.jsm");
+Components.utils.import("resource://zotero/q.js");
+var EventUtils = Components.utils.import("resource://zotero-unit/EventUtils.jsm");
+
+var ZoteroUnit = Components.classes["@mozilla.org/commandlinehandler/general-startup;1?type=zotero-unit"].
+ getService(Components.interfaces.nsISupports).
+ wrappedJSObject;
+var dump = ZoteroUnit.dump;
+
+function quit(failed) {
+ // Quit with exit status
+ if(!failed) {
+ OS.File.writeAtomic(FileUtils.getFile("ProfD", ["success"]).path, Uint8Array(0));
+ }
+ if(!ZoteroUnit.noquit) {
+ Components.classes['@mozilla.org/toolkit/app-startup;1'].
+ getService(Components.interfaces.nsIAppStartup).
+ quit(Components.interfaces.nsIAppStartup.eForceQuit);
+ }
+}
+
+function Reporter(runner) {
+ var indents = 0, passed = 0, failed = 0;
+
+ function indent() {
+ return Array(indents).join(' ');
+ }
+
+ runner.on('start', function(){});
+
+ runner.on('suite', function(suite){
+ ++indents;
+ dump(indent()+suite.title+"\n");
+ });
+
+ runner.on('suite end', function(suite){
+ --indents;
+ if (1 == indents) dump("\n");
+ });
+
+ runner.on('pending', function(test){
+ dump(indent()+"pending -"+test.title);
+ });
+
+ runner.on('pass', function(test){
+ passed++;
+ var msg = "\r"+indent()+Mocha.reporters.Base.symbols.ok+" "+test.title;
+ if ('fast' != test.speed) {
+ msg += " ("+Math.round(test.duration)+" ms)";
+ }
+ dump(msg+"\n");
+ });
+
+ runner.on('fail', function(test, err){
+ failed++;
+ dump("\r"+indent()+Mocha.reporters.Base.symbols.err+" "+test.title+"\n"+
+ indent()+" "+err.toString()+" at\n"+
+ indent()+" "+err.stack.replace("\n", "\n"+indent()+" ", "g"));
+ });
+
+ runner.on('end', function() {
+ dump(passed+"/"+(passed+failed)+" tests passed.\n");
+ quit(failed != 0);
+ });
+}
+
+// Setup Mocha
+mocha.setup({ui:"bdd", reporter:Reporter});
+var assert = chai.assert,
+ expect = chai.expect;
+
+// Set up tests to run
+var run = true;
+if(ZoteroUnit.tests) {
+ var testDirectory = getTestDataDirectory().parent,
+ testFiles = [];
+ if(ZoteroUnit.tests == "all") {
+ var enumerator = testDirectory.directoryEntries;
+ while(enumerator.hasMoreElements()) {
+ var file = enumerator.getNext().QueryInterface(Components.interfaces.nsIFile);
+ if(file.leafName.endsWith(".js")) {
+ testFiles.push(file.leafName);
+ }
+ }
+ } else {
+ var specifiedTests = ZoteroUnit.tests.split(",");
+ for(var test of specifiedTests) {
+ var fname = test+".js",
+ file = testDirectory.clone();
+ file.append(fname);
+ if(!file.exists()) {
+ dump("Invalid test file "+test+"\n");
+ run = false;
+ quit(true);
+ }
+ testFiles.push(fname);
+ }
+ }
+
+ for(var fname of testFiles) {
+ var el = document.createElement("script");
+ el.type = "application/javascript;version=1.8";
+ el.src = "resource://zotero-unit-tests/"+fname;
+ document.body.appendChild(el);
+ }
+}
+
+if(run) {
+ window.onload = function() {
+ mocha.run();
+ };
+}
\ No newline at end of file
diff --git a/test/content/support.js b/test/content/support.js
new file mode 100644
index 000000000..8ff21269c
--- /dev/null
+++ b/test/content/support.js
@@ -0,0 +1,168 @@
+/**
+ * Waits for a DOM event on the specified node. Returns a promise
+ * resolved with the event.
+ */
+function waitForDOMEvent(target, event, capture) {
+ var deferred = Q.defer();
+ var func = function(ev) {
+ target.removeEventListener("event", func, capture);
+ deferred.resolve(ev);
+ }
+ target.addEventListener(event, func, capture);
+ return deferred.promise;
+}
+
+/**
+ * Open a window. Returns a promise for the window.
+ */
+function loadWindow(winurl, argument) {
+ var win = window.openDialog(winurl, "_blank", "chrome", argument);
+ return waitForDOMEvent(win, "load").then(function() {
+ return win;
+ });
+}
+
+/**
+ * Loads a Zotero pane in a new window. Returns the containing window.
+ */
+function loadZoteroPane() {
+ return loadWindow("chrome://browser/content/browser.xul").then(function(win) {
+ win.ZoteroOverlay.toggleDisplay(true);
+
+ // Hack to wait for pane load to finish. This is the same hack
+ // we use in ZoteroPane.js, so either it's not good enough
+ // there or it should be good enough here.
+ return Q.delay(52).then(function() {
+ return win;
+ });
+ });
+}
+
+/**
+ * Waits for a window with a specific URL to open. Returns a promise for the window.
+ */
+function waitForWindow(uri) {
+ var deferred = Q.defer();
+ Components.utils.import("resource://gre/modules/Services.jsm");
+ var loadobserver = function(ev) {
+ ev.originalTarget.removeEventListener("load", loadobserver, false);
+ if(ev.target.location == uri) {
+ Services.ww.unregisterNotification(winobserver);
+ deferred.resolve(ev.target.docShell.QueryInterface(Components.interfaces.nsIInterfaceRequestor).
+ getInterface(Components.interfaces.nsIDOMWindow));
+ }
+ };
+ 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;
+}
+
+/**
+ * Waits for a single item event. Returns a promise for the item ID(s).
+ */
+function waitForItemEvent(event) {
+ var deferred = Q.defer();
+ var notifierID = Zotero.Notifier.registerObserver({notify:function(ev, type, ids, extraData) {
+ if(ev == event) {
+ Zotero.Notifier.unregisterObserver(notifierID);
+ deferred.resolve(ids);
+ }
+ }}, ["item"]);
+ 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 = Q.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;
+}
+
+/**
+ * Ensures that the PDF tools are installed, or installs them if not.
+ * Returns a promise.
+ */
+function installPDFTools() {
+ if(Zotero.Fulltext.pdfConverterIsRegistered() && Zotero.Fulltext.pdfInfoIsRegistered()) {
+ return Q(true);
+ }
+
+ // Begin install procedure
+ return loadWindow("chrome://zotero/content/preferences/preferences.xul", {
+ pane: 'zotero-prefpane-search',
+ action: 'pdftools-install'
+ }).then(function(win) {
+ // Wait for confirmation dialog
+ return waitForWindow("chrome://global/content/commonDialog.xul").then(function(dlg) {
+ // Accept confirmation dialog
+ dlg.document.documentElement.acceptDialog();
+
+ // Wait for install to finish
+ return waitForCallback(function() {
+ return Zotero.Fulltext.pdfConverterIsRegistered() && Zotero.Fulltext.pdfInfoIsRegistered();
+ }, 500, 30000).finally(function() {
+ win.close();
+ });
+ });
+ });
+}
+
+/**
+ * Returns a promise for 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;
+}
+
+/**
+ * Resets the Zotero DB and restarts Zotero. Returns a promise resolved
+ * when this finishes.
+ */
+function resetDB() {
+ var db = Zotero.getZoteroDatabase();
+ return Zotero.reinit(function() {
+ db.remove(false);
+ });
+}
\ No newline at end of file
diff --git a/test/install.rdf b/test/install.rdf
new file mode 100644
index 000000000..86dd153a3
--- /dev/null
+++ b/test/install.rdf
@@ -0,0 +1,26 @@
+
+
+
+
+
+ zotero-unit@zotero.org
+ Zotero Unit Tests
+ 1.0
+ Center for History and New Media
+ Simon Kornblith
+ http://www.zotero.org
+ chrome://zotero/skin/zotero-new-z-48px.png
+ 2
+
+
+
+
+ {ec8030f7-c20a-464f-9b0e-13a3a9e97384}
+ 31.0
+ 38.*
+
+
+
+
+
diff --git a/test/resource/EventUtils.jsm b/test/resource/EventUtils.jsm
new file mode 100644
index 000000000..566c7dde7
--- /dev/null
+++ b/test/resource/EventUtils.jsm
@@ -0,0 +1,835 @@
+/* Taken from MozMill 6c0948d80eebcbb104ce7a776c65aeae634970dd
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ // Export all available functions for Mozmill
+var EXPORTED_SYMBOLS = ["disableNonTestMouseEvents","sendMouseEvent", "sendChar",
+ "sendString", "sendKey", "synthesizeMouse", "synthesizeTouch",
+ "synthesizeMouseAtPoint", "synthesizeTouchAtPoint",
+ "synthesizeMouseAtCenter", "synthesizeTouchAtCenter",
+ "synthesizeWheel", "synthesizeKey",
+ "synthesizeMouseExpectEvent", "synthesizeKeyExpectEvent",
+ "synthesizeText",
+ "synthesizeComposition", "synthesizeQuerySelectedText"];
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+
+var window = Cc["@mozilla.org/appshell/appShellService;1"]
+ .getService(Ci.nsIAppShellService).hiddenDOMWindow;
+
+var _EU_Ci = Ci;
+var navigator = window.navigator;
+var KeyEvent = window.KeyEvent;
+var parent = window.parent;
+
+function is(aExpression1, aExpression2, aMessage) {
+ if (aExpression1 !== aExpression2) {
+ throw new Error(aMessage);
+ }
+}
+
+/**
+ * EventUtils provides some utility methods for creating and sending DOM events.
+ * Current methods:
+ * sendMouseEvent
+ * sendChar
+ * sendString
+ * sendKey
+ * synthesizeMouse
+ * synthesizeMouseAtCenter
+ * synthesizeWheel
+ * synthesizeKey
+ * synthesizeMouseExpectEvent
+ * synthesizeKeyExpectEvent
+ *
+ * When adding methods to this file, please add a performance test for it.
+ */
+
+/**
+ * Send a mouse event to the node aTarget (aTarget can be an id, or an
+ * actual node) . The "event" passed in to aEvent is just a JavaScript
+ * object with the properties set that the real mouse event object should
+ * have. This includes the type of the mouse event.
+ * E.g. to send an click event to the node with id 'node' you might do this:
+ *
+ * sendMouseEvent({type:'click'}, 'node');
+ */
+function getElement(id) {
+ return ((typeof(id) == "string") ?
+ document.getElementById(id) : id);
+};
+
+this.$ = this.getElement;
+
+function sendMouseEvent(aEvent, aTarget, aWindow) {
+ if (['click', 'dblclick', 'mousedown', 'mouseup', 'mouseover', 'mouseout'].indexOf(aEvent.type) == -1) {
+ throw new Error("sendMouseEvent doesn't know about event type '" + aEvent.type + "'");
+ }
+
+ if (!aWindow) {
+ aWindow = window;
+ }
+
+ if (!(aTarget instanceof aWindow.Element)) {
+ aTarget = aWindow.document.getElementById(aTarget);
+ }
+
+ var event = aWindow.document.createEvent('MouseEvent');
+
+ var typeArg = aEvent.type;
+ var canBubbleArg = true;
+ var cancelableArg = true;
+ var viewArg = aWindow;
+ var detailArg = aEvent.detail || (aEvent.type == 'click' ||
+ aEvent.type == 'mousedown' ||
+ aEvent.type == 'mouseup' ? 1 :
+ aEvent.type == 'dblclick'? 2 : 0);
+ var screenXArg = aEvent.screenX || 0;
+ var screenYArg = aEvent.screenY || 0;
+ var clientXArg = aEvent.clientX || 0;
+ var clientYArg = aEvent.clientY || 0;
+ var ctrlKeyArg = aEvent.ctrlKey || false;
+ var altKeyArg = aEvent.altKey || false;
+ var shiftKeyArg = aEvent.shiftKey || false;
+ var metaKeyArg = aEvent.metaKey || false;
+ var buttonArg = aEvent.button || 0;
+ var relatedTargetArg = aEvent.relatedTarget || null;
+
+ event.initMouseEvent(typeArg, canBubbleArg, cancelableArg, viewArg, detailArg,
+ screenXArg, screenYArg, clientXArg, clientYArg,
+ ctrlKeyArg, altKeyArg, shiftKeyArg, metaKeyArg,
+ buttonArg, relatedTargetArg);
+
+ aTarget.dispatchEvent(event);
+}
+
+/**
+ * Send the char aChar to the focused element. This method handles casing of
+ * chars (sends the right charcode, and sends a shift key for uppercase chars).
+ * No other modifiers are handled at this point.
+ *
+ * For now this method only works for ASCII characters and emulates the shift
+ * key state on US keyboard layout.
+ */
+function sendChar(aChar, aWindow) {
+ var hasShift;
+ // Emulate US keyboard layout for the shiftKey state.
+ switch (aChar) {
+ case "!":
+ case "@":
+ case "#":
+ case "$":
+ case "%":
+ case "^":
+ case "&":
+ case "*":
+ case "(":
+ case ")":
+ case "_":
+ case "+":
+ case "{":
+ case "}":
+ case ":":
+ case "\"":
+ case "|":
+ case "<":
+ case ">":
+ case "?":
+ hasShift = true;
+ break;
+ default:
+ hasShift = (aChar == aChar.toUpperCase());
+ break;
+ }
+ synthesizeKey(aChar, { shiftKey: hasShift }, aWindow);
+}
+
+/**
+ * Send the string aStr to the focused element.
+ *
+ * For now this method only works for ASCII characters and emulates the shift
+ * key state on US keyboard layout.
+ */
+function sendString(aStr, aWindow) {
+ for (var i = 0; i < aStr.length; ++i) {
+ sendChar(aStr.charAt(i), aWindow);
+ }
+}
+
+/**
+ * Send the non-character key aKey to the focused node.
+ * The name of the key should be the part that comes after "DOM_VK_" in the
+ * KeyEvent constant name for this key.
+ * No modifiers are handled at this point.
+ */
+function sendKey(aKey, aWindow) {
+ var keyName = "VK_" + aKey.toUpperCase();
+ synthesizeKey(keyName, { shiftKey: false }, aWindow);
+}
+
+/**
+ * Parse the key modifier flags from aEvent. Used to share code between
+ * synthesizeMouse and synthesizeKey.
+ */
+function _parseModifiers(aEvent)
+{
+ const nsIDOMWindowUtils = _EU_Ci.nsIDOMWindowUtils;
+ var mval = 0;
+ if (aEvent.shiftKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_SHIFT;
+ }
+ if (aEvent.ctrlKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_CONTROL;
+ }
+ if (aEvent.altKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_ALT;
+ }
+ if (aEvent.metaKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_META;
+ }
+ if (aEvent.accelKey) {
+ mval |= (navigator.platform.indexOf("Mac") >= 0) ?
+ nsIDOMWindowUtils.MODIFIER_META : nsIDOMWindowUtils.MODIFIER_CONTROL;
+ }
+ if (aEvent.altGrKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_ALTGRAPH;
+ }
+ if (aEvent.capsLockKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_CAPSLOCK;
+ }
+ if (aEvent.fnKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_FN;
+ }
+ if (aEvent.numLockKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_NUMLOCK;
+ }
+ if (aEvent.scrollLockKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_SCROLLLOCK;
+ }
+ if (aEvent.symbolLockKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_SYMBOLLOCK;
+ }
+ if (aEvent.osKey) {
+ mval |= nsIDOMWindowUtils.MODIFIER_OS;
+ }
+
+ return mval;
+}
+
+/**
+ * Synthesize a mouse event on a target. The actual client point is determined
+ * by taking the aTarget's client box and offseting it by aOffsetX and
+ * aOffsetY. This allows mouse clicks to be simulated by calling this method.
+ *
+ * aEvent is an object which may contain the properties:
+ * shiftKey, ctrlKey, altKey, metaKey, accessKey, clickCount, button, type
+ *
+ * If the type is specified, an mouse event of that type is fired. Otherwise,
+ * a mousedown followed by a mouse up is performed.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ *
+ * Returns whether the event had preventDefault() called on it.
+ */
+function synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow)
+{
+ var rect = aTarget.getBoundingClientRect();
+ return synthesizeMouseAtPoint(rect.left + aOffsetX, rect.top + aOffsetY,
+ aEvent, aWindow);
+}
+function synthesizeTouch(aTarget, aOffsetX, aOffsetY, aEvent, aWindow)
+{
+ var rect = aTarget.getBoundingClientRect();
+ synthesizeTouchAtPoint(rect.left + aOffsetX, rect.top + aOffsetY,
+ aEvent, aWindow);
+}
+
+/*
+ * Synthesize a mouse event at a particular point in aWindow.
+ *
+ * aEvent is an object which may contain the properties:
+ * shiftKey, ctrlKey, altKey, metaKey, accessKey, clickCount, button, type
+ *
+ * If the type is specified, an mouse event of that type is fired. Otherwise,
+ * a mousedown followed by a mouse up is performed.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeMouseAtPoint(left, top, aEvent, aWindow)
+{
+ var utils = _getDOMWindowUtils(aWindow);
+ var defaultPrevented = false;
+
+ if (utils) {
+ var button = aEvent.button || 0;
+ var clickCount = aEvent.clickCount || 1;
+ var modifiers = _parseModifiers(aEvent);
+ var pressure = ("pressure" in aEvent) ? aEvent.pressure : 0;
+ var inputSource = ("inputSource" in aEvent) ? aEvent.inputSource : 0;
+
+ if (("type" in aEvent) && aEvent.type) {
+ defaultPrevented = utils.sendMouseEvent(aEvent.type, left, top, button, clickCount, modifiers, false, pressure, inputSource);
+ }
+ else {
+ utils.sendMouseEvent("mousedown", left, top, button, clickCount, modifiers, false, pressure, inputSource);
+ utils.sendMouseEvent("mouseup", left, top, button, clickCount, modifiers, false, pressure, inputSource);
+ }
+ }
+
+ return defaultPrevented;
+}
+function synthesizeTouchAtPoint(left, top, aEvent, aWindow)
+{
+ var utils = _getDOMWindowUtils(aWindow);
+
+ if (utils) {
+ var id = aEvent.id || 0;
+ var rx = aEvent.rx || 1;
+ var ry = aEvent.rx || 1;
+ var angle = aEvent.angle || 0;
+ var force = aEvent.force || 1;
+ var modifiers = _parseModifiers(aEvent);
+
+ if (("type" in aEvent) && aEvent.type) {
+ utils.sendTouchEvent(aEvent.type, [id], [left], [top], [rx], [ry], [angle], [force], 1, modifiers);
+ }
+ else {
+ utils.sendTouchEvent("touchstart", [id], [left], [top], [rx], [ry], [angle], [force], 1, modifiers);
+ utils.sendTouchEvent("touchend", [id], [left], [top], [rx], [ry], [angle], [force], 1, modifiers);
+ }
+ }
+}
+// Call synthesizeMouse with coordinates at the center of aTarget.
+function synthesizeMouseAtCenter(aTarget, aEvent, aWindow)
+{
+ var rect = aTarget.getBoundingClientRect();
+ synthesizeMouse(aTarget, rect.width / 2, rect.height / 2, aEvent,
+ aWindow);
+}
+function synthesizeTouchAtCenter(aTarget, aEvent, aWindow)
+{
+ var rect = aTarget.getBoundingClientRect();
+ synthesizeTouch(aTarget, rect.width / 2, rect.height / 2, aEvent,
+ aWindow);
+}
+
+/**
+ * Synthesize a wheel event on a target. The actual client point is determined
+ * by taking the aTarget's client box and offseting it by aOffsetX and
+ * aOffsetY.
+ *
+ * aEvent is an object which may contain the properties:
+ * shiftKey, ctrlKey, altKey, metaKey, accessKey, deltaX, deltaY, deltaZ,
+ * deltaMode, lineOrPageDeltaX, lineOrPageDeltaY, isMomentum, isPixelOnlyDevice,
+ * isCustomizedByPrefs, expectedOverflowDeltaX, expectedOverflowDeltaY
+ *
+ * deltaMode must be defined, others are ok even if undefined.
+ *
+ * expectedOverflowDeltaX and expectedOverflowDeltaY take integer value. The
+ * value is just checked as 0 or positive or negative.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeWheel(aTarget, aOffsetX, aOffsetY, aEvent, aWindow)
+{
+ var utils = _getDOMWindowUtils(aWindow);
+ if (!utils) {
+ return;
+ }
+
+ var modifiers = _parseModifiers(aEvent);
+ var options = 0;
+ if (aEvent.isPixelOnlyDevice &&
+ (aEvent.deltaMode == WheelEvent.DOM_DELTA_PIXEL)) {
+ options |= utils.WHEEL_EVENT_CAUSED_BY_PIXEL_ONLY_DEVICE;
+ }
+ if (aEvent.isMomentum) {
+ options |= utils.WHEEL_EVENT_CAUSED_BY_MOMENTUM;
+ }
+ if (aEvent.isCustomizedByPrefs) {
+ options |= utils.WHEEL_EVENT_CUSTOMIZED_BY_USER_PREFS;
+ }
+ if (typeof aEvent.expectedOverflowDeltaX !== "undefined") {
+ if (aEvent.expectedOverflowDeltaX === 0) {
+ options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_X_ZERO;
+ } else if (aEvent.expectedOverflowDeltaX > 0) {
+ options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_X_POSITIVE;
+ } else {
+ options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_X_NEGATIVE;
+ }
+ }
+ if (typeof aEvent.expectedOverflowDeltaY !== "undefined") {
+ if (aEvent.expectedOverflowDeltaY === 0) {
+ options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_Y_ZERO;
+ } else if (aEvent.expectedOverflowDeltaY > 0) {
+ options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_Y_POSITIVE;
+ } else {
+ options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_Y_NEGATIVE;
+ }
+ }
+ var isPixelOnlyDevice =
+ aEvent.isPixelOnlyDevice && aEvent.deltaMode == WheelEvent.DOM_DELTA_PIXEL;
+
+ // Avoid the JS warnings "reference to undefined property"
+ if (!aEvent.deltaX) {
+ aEvent.deltaX = 0;
+ }
+ if (!aEvent.deltaY) {
+ aEvent.deltaY = 0;
+ }
+ if (!aEvent.deltaZ) {
+ aEvent.deltaZ = 0;
+ }
+
+ var lineOrPageDeltaX =
+ aEvent.lineOrPageDeltaX != null ? aEvent.lineOrPageDeltaX :
+ aEvent.deltaX > 0 ? Math.floor(aEvent.deltaX) :
+ Math.ceil(aEvent.deltaX);
+ var lineOrPageDeltaY =
+ aEvent.lineOrPageDeltaY != null ? aEvent.lineOrPageDeltaY :
+ aEvent.deltaY > 0 ? Math.floor(aEvent.deltaY) :
+ Math.ceil(aEvent.deltaY);
+
+ var rect = aTarget.getBoundingClientRect();
+ utils.sendWheelEvent(rect.left + aOffsetX, rect.top + aOffsetY,
+ aEvent.deltaX, aEvent.deltaY, aEvent.deltaZ,
+ aEvent.deltaMode, modifiers,
+ lineOrPageDeltaX, lineOrPageDeltaY, options);
+}
+
+function _computeKeyCodeFromChar(aChar)
+{
+ if (aChar.length != 1) {
+ return 0;
+ }
+ const nsIDOMKeyEvent = _EU_Ci.nsIDOMKeyEvent;
+ if (aChar >= 'a' && aChar <= 'z') {
+ return nsIDOMKeyEvent.DOM_VK_A + aChar.charCodeAt(0) - 'a'.charCodeAt(0);
+ }
+ if (aChar >= 'A' && aChar <= 'Z') {
+ return nsIDOMKeyEvent.DOM_VK_A + aChar.charCodeAt(0) - 'A'.charCodeAt(0);
+ }
+ if (aChar >= '0' && aChar <= '9') {
+ return nsIDOMKeyEvent.DOM_VK_0 + aChar.charCodeAt(0) - '0'.charCodeAt(0);
+ }
+ // returns US keyboard layout's keycode
+ switch (aChar) {
+ case '~':
+ case '`':
+ return nsIDOMKeyEvent.DOM_VK_BACK_QUOTE;
+ case '!':
+ return nsIDOMKeyEvent.DOM_VK_1;
+ case '@':
+ return nsIDOMKeyEvent.DOM_VK_2;
+ case '#':
+ return nsIDOMKeyEvent.DOM_VK_3;
+ case '$':
+ return nsIDOMKeyEvent.DOM_VK_4;
+ case '%':
+ return nsIDOMKeyEvent.DOM_VK_5;
+ case '^':
+ return nsIDOMKeyEvent.DOM_VK_6;
+ case '&':
+ return nsIDOMKeyEvent.DOM_VK_7;
+ case '*':
+ return nsIDOMKeyEvent.DOM_VK_8;
+ case '(':
+ return nsIDOMKeyEvent.DOM_VK_9;
+ case ')':
+ return nsIDOMKeyEvent.DOM_VK_0;
+ case '-':
+ case '_':
+ return nsIDOMKeyEvent.DOM_VK_SUBTRACT;
+ case '+':
+ case '=':
+ return nsIDOMKeyEvent.DOM_VK_EQUALS;
+ case '{':
+ case '[':
+ return nsIDOMKeyEvent.DOM_VK_OPEN_BRACKET;
+ case '}':
+ case ']':
+ return nsIDOMKeyEvent.DOM_VK_CLOSE_BRACKET;
+ case '|':
+ case '\\':
+ return nsIDOMKeyEvent.DOM_VK_BACK_SLASH;
+ case ':':
+ case ';':
+ return nsIDOMKeyEvent.DOM_VK_SEMICOLON;
+ case '\'':
+ case '"':
+ return nsIDOMKeyEvent.DOM_VK_QUOTE;
+ case '<':
+ case ',':
+ return nsIDOMKeyEvent.DOM_VK_COMMA;
+ case '>':
+ case '.':
+ return nsIDOMKeyEvent.DOM_VK_PERIOD;
+ case '?':
+ case '/':
+ return nsIDOMKeyEvent.DOM_VK_SLASH;
+ default:
+ return 0;
+ }
+}
+
+/**
+ * isKeypressFiredKey() returns TRUE if the given key should cause keypress
+ * event when widget handles the native key event. Otherwise, FALSE.
+ *
+ * aDOMKeyCode should be one of consts of nsIDOMKeyEvent::DOM_VK_*, or a key
+ * name begins with "VK_", or a character.
+ */
+function isKeypressFiredKey(aDOMKeyCode)
+{
+ if (typeof(aDOMKeyCode) == "string") {
+ if (aDOMKeyCode.indexOf("VK_") == 0) {
+ aDOMKeyCode = KeyEvent["DOM_" + aDOMKeyCode];
+ if (!aDOMKeyCode) {
+ throw "Unknown key: " + aDOMKeyCode;
+ }
+ } else {
+ // If the key generates a character, it must cause a keypress event.
+ return true;
+ }
+ }
+ switch (aDOMKeyCode) {
+ case KeyEvent.DOM_VK_SHIFT:
+ case KeyEvent.DOM_VK_CONTROL:
+ case KeyEvent.DOM_VK_ALT:
+ case KeyEvent.DOM_VK_CAPS_LOCK:
+ case KeyEvent.DOM_VK_NUM_LOCK:
+ case KeyEvent.DOM_VK_SCROLL_LOCK:
+ case KeyEvent.DOM_VK_META:
+ return false;
+ default:
+ return true;
+ }
+}
+
+/**
+ * Synthesize a key event. It is targeted at whatever would be targeted by an
+ * actual keypress by the user, typically the focused element.
+ *
+ * aKey should be either a character or a keycode starting with VK_ such as
+ * VK_ENTER.
+ *
+ * aEvent is an object which may contain the properties:
+ * shiftKey, ctrlKey, altKey, metaKey, accessKey, type, location
+ *
+ * Sets one of KeyboardEvent.DOM_KEY_LOCATION_* to location. Otherwise,
+ * DOMWindowUtils will choose good location from the keycode.
+ *
+ * If the type is specified, a key event of that type is fired. Otherwise,
+ * a keydown, a keypress and then a keyup event are fired in sequence.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeKey(aKey, aEvent, aWindow)
+{
+ var utils = _getDOMWindowUtils(aWindow);
+ if (utils) {
+ var keyCode = 0, charCode = 0;
+ if (aKey.indexOf("VK_") == 0) {
+ keyCode = KeyEvent["DOM_" + aKey];
+ if (!keyCode) {
+ throw "Unknown key: " + aKey;
+ }
+ } else {
+ charCode = aKey.charCodeAt(0);
+ keyCode = _computeKeyCodeFromChar(aKey.charAt(0));
+ }
+
+ var modifiers = _parseModifiers(aEvent);
+ var flags = 0;
+ if (aEvent.location != undefined) {
+ switch (aEvent.location) {
+ case KeyboardEvent.DOM_KEY_LOCATION_STANDARD:
+ flags |= utils.KEY_FLAG_LOCATION_STANDARD;
+ break;
+ case KeyboardEvent.DOM_KEY_LOCATION_LEFT:
+ flags |= utils.KEY_FLAG_LOCATION_LEFT;
+ break;
+ case KeyboardEvent.DOM_KEY_LOCATION_RIGHT:
+ flags |= utils.KEY_FLAG_LOCATION_RIGHT;
+ break;
+ case KeyboardEvent.DOM_KEY_LOCATION_NUMPAD:
+ flags |= utils.KEY_FLAG_LOCATION_NUMPAD;
+ break;
+ case KeyboardEvent.DOM_KEY_LOCATION_MOBILE:
+ flags |= utils.KEY_FLAG_LOCATION_MOBILE;
+ break;
+ case KeyboardEvent.DOM_KEY_LOCATION_JOYSTICK:
+ flags |= utils.KEY_FLAG_LOCATION_JOYSTICK;
+ break;
+ }
+ }
+
+ if (!("type" in aEvent) || !aEvent.type) {
+ // Send keydown + (optional) keypress + keyup events.
+ var keyDownDefaultHappened =
+ utils.sendKeyEvent("keydown", keyCode, 0, modifiers, flags);
+ if (isKeypressFiredKey(keyCode)) {
+ if (!keyDownDefaultHappened) {
+ flags |= utils.KEY_FLAG_PREVENT_DEFAULT;
+ }
+ utils.sendKeyEvent("keypress", keyCode, charCode, modifiers, flags);
+ }
+ utils.sendKeyEvent("keyup", keyCode, 0, modifiers, flags);
+ } else if (aEvent.type == "keypress") {
+ // Send standalone keypress event.
+ utils.sendKeyEvent(aEvent.type, keyCode, charCode, modifiers, flags);
+ } else {
+ // Send other standalone event than keypress.
+ utils.sendKeyEvent(aEvent.type, keyCode, 0, modifiers, flags);
+ }
+ }
+}
+
+var _gSeenEvent = false;
+
+/**
+ * Indicate that an event with an original target of aExpectedTarget and
+ * a type of aExpectedEvent is expected to be fired, or not expected to
+ * be fired.
+ */
+function _expectEvent(aExpectedTarget, aExpectedEvent, aTestName)
+{
+ if (!aExpectedTarget || !aExpectedEvent)
+ return null;
+
+ _gSeenEvent = false;
+
+ var type = (aExpectedEvent.charAt(0) == "!") ?
+ aExpectedEvent.substring(1) : aExpectedEvent;
+ var eventHandler = function(event) {
+ var epassed = (!_gSeenEvent && event.originalTarget == aExpectedTarget &&
+ event.type == type);
+ is(epassed, true, aTestName + " " + type + " event target " + (_gSeenEvent ? "twice" : ""));
+ _gSeenEvent = true;
+ };
+
+ aExpectedTarget.addEventListener(type, eventHandler, false);
+ return eventHandler;
+}
+
+/**
+ * Check if the event was fired or not. The event handler aEventHandler
+ * will be removed.
+ */
+function _checkExpectedEvent(aExpectedTarget, aExpectedEvent, aEventHandler, aTestName)
+{
+ if (aEventHandler) {
+ var expectEvent = (aExpectedEvent.charAt(0) != "!");
+ var type = expectEvent ? aExpectedEvent : aExpectedEvent.substring(1);
+ aExpectedTarget.removeEventListener(type, aEventHandler, false);
+ var desc = type + " event";
+ if (!expectEvent)
+ desc += " not";
+ is(_gSeenEvent, expectEvent, aTestName + " " + desc + " fired");
+ }
+
+ _gSeenEvent = false;
+}
+
+/**
+ * Similar to synthesizeMouse except that a test is performed to see if an
+ * event is fired at the right target as a result.
+ *
+ * aExpectedTarget - the expected originalTarget of the event.
+ * aExpectedEvent - the expected type of the event, such as 'select'.
+ * aTestName - the test name when outputing results
+ *
+ * To test that an event is not fired, use an expected type preceded by an
+ * exclamation mark, such as '!select'. This might be used to test that a
+ * click on a disabled element doesn't fire certain events for instance.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeMouseExpectEvent(aTarget, aOffsetX, aOffsetY, aEvent,
+ aExpectedTarget, aExpectedEvent, aTestName,
+ aWindow)
+{
+ var eventHandler = _expectEvent(aExpectedTarget, aExpectedEvent, aTestName);
+ synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow);
+ _checkExpectedEvent(aExpectedTarget, aExpectedEvent, eventHandler, aTestName);
+}
+
+/**
+ * Similar to synthesizeKey except that a test is performed to see if an
+ * event is fired at the right target as a result.
+ *
+ * aExpectedTarget - the expected originalTarget of the event.
+ * aExpectedEvent - the expected type of the event, such as 'select'.
+ * aTestName - the test name when outputing results
+ *
+ * To test that an event is not fired, use an expected type preceded by an
+ * exclamation mark, such as '!select'.
+ *
+ * aWindow is optional, and defaults to the current window object.
+ */
+function synthesizeKeyExpectEvent(key, aEvent, aExpectedTarget, aExpectedEvent,
+ aTestName, aWindow)
+{
+ var eventHandler = _expectEvent(aExpectedTarget, aExpectedEvent, aTestName);
+ synthesizeKey(key, aEvent, aWindow);
+ _checkExpectedEvent(aExpectedTarget, aExpectedEvent, eventHandler, aTestName);
+}
+
+function disableNonTestMouseEvents(aDisable)
+{
+ var domutils = _getDOMWindowUtils();
+ domutils.disableNonTestMouseEvents(aDisable);
+}
+
+function _getDOMWindowUtils(aWindow)
+{
+ if (!aWindow) {
+ aWindow = window;
+ }
+
+ // we need parent.SpecialPowers for:
+ // layout/base/tests/test_reftests_with_caret.html
+ // chrome: toolkit/content/tests/chrome/test_findbar.xul
+ // chrome: toolkit/content/tests/chrome/test_popup_anchor.xul
+ if ("SpecialPowers" in window && window.SpecialPowers != undefined) {
+ return SpecialPowers.getDOMWindowUtils(aWindow);
+ }
+ if ("SpecialPowers" in parent && parent.SpecialPowers != undefined) {
+ return parent.SpecialPowers.getDOMWindowUtils(aWindow);
+ }
+
+ //TODO: this is assuming we are in chrome space
+ return aWindow.QueryInterface(_EU_Ci.nsIInterfaceRequestor).
+ getInterface(_EU_Ci.nsIDOMWindowUtils);
+}
+
+// Must be synchronized with nsIDOMWindowUtils.
+const COMPOSITION_ATTR_RAWINPUT = 0x02;
+const COMPOSITION_ATTR_SELECTEDRAWTEXT = 0x03;
+const COMPOSITION_ATTR_CONVERTEDTEXT = 0x04;
+const COMPOSITION_ATTR_SELECTEDCONVERTEDTEXT = 0x05;
+
+/**
+ * Synthesize a composition event.
+ *
+ * @param aEvent The composition event information. This must
+ * have |type| member. The value must be
+ * "compositionstart", "compositionend" or
+ * "compositionupdate".
+ * And also this may have |data| and |locale| which
+ * would be used for the value of each property of
+ * the composition event. Note that the data would
+ * be ignored if the event type were
+ * "compositionstart".
+ * @param aWindow Optional (If null, current |window| will be used)
+ */
+function synthesizeComposition(aEvent, aWindow)
+{
+ var utils = _getDOMWindowUtils(aWindow);
+ if (!utils) {
+ return;
+ }
+
+ utils.sendCompositionEvent(aEvent.type, aEvent.data ? aEvent.data : "",
+ aEvent.locale ? aEvent.locale : "");
+}
+/**
+ * Synthesize a text event.
+ *
+ * @param aEvent The text event's information, this has |composition|
+ * and |caret| members. |composition| has |string| and
+ * |clauses| members. |clauses| must be array object. Each
+ * object has |length| and |attr|. And |caret| has |start| and
+ * |length|. See the following tree image.
+ *
+ * aEvent
+ * +-- composition
+ * | +-- string
+ * | +-- clauses[]
+ * | +-- length
+ * | +-- attr
+ * +-- caret
+ * +-- start
+ * +-- length
+ *
+ * Set the composition string to |composition.string|. Set its
+ * clauses information to the |clauses| array.
+ *
+ * When it's composing, set the each clauses' length to the
+ * |composition.clauses[n].length|. The sum of the all length
+ * values must be same as the length of |composition.string|.
+ * Set nsIDOMWindowUtils.COMPOSITION_ATTR_* to the
+ * |composition.clauses[n].attr|.
+ *
+ * When it's not composing, set 0 to the
+ * |composition.clauses[0].length| and
+ * |composition.clauses[0].attr|.
+ *
+ * Set caret position to the |caret.start|. It's offset from
+ * the start of the composition string. Set caret length to
+ * |caret.length|. If it's larger than 0, it should be wide
+ * caret. However, current nsEditor doesn't support wide
+ * caret, therefore, you should always set 0 now.
+ *
+ * @param aWindow Optional (If null, current |window| will be used)
+ */
+function synthesizeText(aEvent, aWindow)
+{
+ var utils = _getDOMWindowUtils(aWindow);
+ if (!utils) {
+ return;
+ }
+
+ if (!aEvent.composition || !aEvent.composition.clauses ||
+ !aEvent.composition.clauses[0]) {
+ return;
+ }
+
+ var firstClauseLength = aEvent.composition.clauses[0].length;
+ var firstClauseAttr = aEvent.composition.clauses[0].attr;
+ var secondClauseLength = 0;
+ var secondClauseAttr = 0;
+ var thirdClauseLength = 0;
+ var thirdClauseAttr = 0;
+ if (aEvent.composition.clauses[1]) {
+ secondClauseLength = aEvent.composition.clauses[1].length;
+ secondClauseAttr = aEvent.composition.clauses[1].attr;
+ if (aEvent.composition.clauses[2]) {
+ thirdClauseLength = aEvent.composition.clauses[2].length;
+ thirdClauseAttr = aEvent.composition.clauses[2].attr;
+ }
+ }
+
+ var caretStart = -1;
+ var caretLength = 0;
+ if (aEvent.caret) {
+ caretStart = aEvent.caret.start;
+ caretLength = aEvent.caret.length;
+ }
+
+ utils.sendTextEvent(aEvent.composition.string,
+ firstClauseLength, firstClauseAttr,
+ secondClauseLength, secondClauseAttr,
+ thirdClauseLength, thirdClauseAttr,
+ caretStart, caretLength);
+}
+
+/**
+ * Synthesize a query selected text event.
+ *
+ * @param aWindow Optional (If null, current |window| will be used)
+ * @return An nsIQueryContentEventResult object. If this failed,
+ * the result might be null.
+ */
+function synthesizeQuerySelectedText(aWindow)
+{
+ var utils = _getDOMWindowUtils(aWindow);
+ if (!utils) {
+ return null;
+ }
+
+ return utils.sendQueryContentEvent(utils.QUERY_SELECTED_TEXT, 0, 0, 0, 0);
+}
diff --git a/test/resource/chai b/test/resource/chai
new file mode 160000
index 000000000..d7cafca02
--- /dev/null
+++ b/test/resource/chai
@@ -0,0 +1 @@
+Subproject commit d7cafca0232756f767275bb00e66930a7823b027
diff --git a/test/resource/mocha b/test/resource/mocha
new file mode 160000
index 000000000..65fc80ecd
--- /dev/null
+++ b/test/resource/mocha
@@ -0,0 +1 @@
+Subproject commit 65fc80ecd96ca2159a792aff089bbc273d4bd86d
diff --git a/test/runtests.sh b/test/runtests.sh
new file mode 100755
index 000000000..7ef72c07c
--- /dev/null
+++ b/test/runtests.sh
@@ -0,0 +1,71 @@
+#!/bin/bash
+CWD="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+
+DEBUG=false
+if [ "`uname`" == "Darwin" ]; then
+ FX_EXECUTABLE="/Applications/Firefox.app/Contents/MacOS/firefox"
+else
+ FX_EXECUTABLE="firefox"
+fi
+FX_ARGS=""
+
+function usage {
+ cat >&2 </dev/null || mktemp -d -t 'zotero-unit'`"
+mkdir "$PROFILE/extensions"
+echo "$CWD" > "$PROFILE/extensions/zotero-unit@zotero.org"
+echo "`dirname "$CWD"`" > "$PROFILE/extensions/zotero@chnm.gmu.edu"
+cat < "$PROFILE/prefs.js"
+user_pref("extensions.autoDisableScopes", 0);
+user_pref("extensions.zotero.debug.log", $DEBUG);
+user_pref("extensions.zotero.firstRunGuidance", false);
+user_pref("extensions.zotero.firstRun2", false);
+EOF
+
+MOZ_NO_REMOTE=1 NO_EM_RESTART=1 "$FX_EXECUTABLE" -profile "$PROFILE" \
+ -chrome chrome://zotero-unit/content/runtests.html -test "$TESTS" $FX_ARGS
+
+# Check for success
+test -e "$PROFILE/success"
+STATUS=$?
+
+# Clean up
+rm -rf "$PROFILE"
+exit $STATUS
\ No newline at end of file
diff --git a/test/tests/data/recognizePDF_test_DOI.pdf b/test/tests/data/recognizePDF_test_DOI.pdf
new file mode 100644
index 000000000..324e83cee
Binary files /dev/null and b/test/tests/data/recognizePDF_test_DOI.pdf differ
diff --git a/test/tests/data/recognizePDF_test_GS.pdf b/test/tests/data/recognizePDF_test_GS.pdf
new file mode 100644
index 000000000..28d78b5ca
Binary files /dev/null and b/test/tests/data/recognizePDF_test_GS.pdf differ
diff --git a/test/tests/lookup.js b/test/tests/lookup.js
new file mode 100644
index 000000000..f49bef575
--- /dev/null
+++ b/test/tests/lookup.js
@@ -0,0 +1,51 @@
+function lookupIdentifier(win, identifier) {
+ var textbox = win.document.getElementById("zotero-lookup-textbox");
+ textbox.value = identifier;
+ win.Zotero_Lookup.accept(textbox);
+ return waitForItemEvent("add");
+}
+
+describe("Add Item by Identifier", function() {
+ var win;
+ before(function() {
+ this.timeout(5000);
+ // Load a Zotero pane and update the translators (needed to
+ // make sure they're available before we run the tests)
+ return loadZoteroPane().then(function(w) {
+ win = w;
+ return Zotero.Schema.updateBundledFiles('translators', null, false);
+ });
+ });
+ after(function() {
+ win.close();
+ });
+
+ it("should add an ISBN-10", function() {
+ this.timeout(10000);
+ return lookupIdentifier(win, "0838985890").then(function(ids) {
+ var item = Zotero.Items.get(ids[0]);
+ assert.equal(item.getField("title"), "Zotero: a guide for librarians, researchers, and educators");
+ });
+ });
+ it("should add an ISBN-13", function() {
+ this.timeout(10000);
+ return lookupIdentifier(win, "978-0838985892").then(function(ids) {
+ var item = Zotero.Items.get(ids[0]);
+ assert.equal(item.getField("title"), "Zotero: a guide for librarians, researchers, and educators");
+ });
+ });
+ it("should add a DOI", function() {
+ this.timeout(10000);
+ return lookupIdentifier(win, "10.4103/0976-500X.85940").then(function(ids) {
+ var item = Zotero.Items.get(ids[0]);
+ assert.equal(item.getField("title"), "Zotero: A bibliographic assistant to researcher");
+ });
+ });
+ it("should add a PMID", function() {
+ this.timeout(10000);
+ return lookupIdentifier(win, "24297125").then(function(ids) {
+ var item = Zotero.Items.get(ids[0]);
+ assert.equal(item.getField("title"), "Taking control of your digital library: how modern citation managers do more than just referencing");
+ });
+ });
+});
\ No newline at end of file
diff --git a/test/tests/recognizePDF.js b/test/tests/recognizePDF.js
new file mode 100644
index 000000000..efba88d3a
--- /dev/null
+++ b/test/tests/recognizePDF.js
@@ -0,0 +1,58 @@
+describe("PDF Recognition", function() {
+ Components.utils.import("resource://gre/modules/FileUtils.jsm");
+
+ var win;
+ before(function() {
+ this.timeout(60000);
+ // Load Zotero pane, install PDF tools, and load the
+ // translators
+ return Q.all([loadZoteroPane().then(function(w) {
+ win = w;
+ return Zotero.Schema.updateBundledFiles('translators', null, false);
+ }), installPDFTools()]);
+ });
+ afterEach(function() {
+ for(let win of getWindows("chrome://zotero/content/pdfProgress.xul")) {
+ win.close();
+ }
+ });
+ after(function() {
+ win.close();
+ });
+
+ it("should recognize a PDF with a DOI", function() {
+ this.timeout(30000);
+ // Import the PDF
+ var testdir = getTestDataDirectory();
+ testdir.append("recognizePDF_test_DOI.pdf");
+ var id = Zotero.Attachments.importFromFile(testdir);
+
+ // Recognize the PDF
+ win.ZoteroPane.selectItem(id);
+ win.Zotero_RecognizePDF.recognizeSelected();
+
+ return waitForItemEvent("add").then(function(ids) {
+ var item = Zotero.Items.get(ids[0]);
+ assert.equal(item.getField("title"), "Shaping the Research Agenda");
+ assert.equal(item.getField("libraryCatalog"), "CrossRef");
+ });
+ });
+
+ it("should recognize a PDF without a DOI", function() {
+ this.timeout(30000);
+ // Import the PDF
+ var testdir = getTestDataDirectory();
+ testdir.append("recognizePDF_test_GS.pdf");
+ var id = Zotero.Attachments.importFromFile(testdir);
+
+ // Recognize the PDF
+ win.ZoteroPane.selectItem(id);
+ win.Zotero_RecognizePDF.recognizeSelected();
+
+ return waitForItemEvent("add").then(function(ids) {
+ var item = Zotero.Items.get(ids[0]);
+ assert.equal(item.getField("title"), "Scaling study of an improved fermion action on quenched lattices");
+ assert.equal(item.getField("libraryCatalog"), "Google Scholar");
+ });
+ });
+});
\ No newline at end of file
diff --git a/test/tests/support.js b/test/tests/support.js
new file mode 100644
index 000000000..f8c91f6f3
--- /dev/null
+++ b/test/tests/support.js
@@ -0,0 +1,11 @@
+describe("Support Functions for Unit Testing", function() {
+ describe("resetDB", function() {
+ it("should restore the DB to factory settings", function() {
+ var quickstart = Zotero.Items.erase(1);
+ assert.equal(Zotero.Items.get(1), false);
+ return resetDB().then(function() {
+ assert.equal(Zotero.Items.get(1).getField("url"), "http://zotero.org/support/quick_start_guide");
+ });
+ });
+ });
+});
diff --git a/test/tests/utilities.js b/test/tests/utilities.js
new file mode 100644
index 000000000..b71dcb517
--- /dev/null
+++ b/test/tests/utilities.js
@@ -0,0 +1,20 @@
+describe("Zotero.Utilities", function() {
+ describe("cleanAuthor", function() {
+ it('should parse author names', function() {
+ for(let useComma of [false, true]) {
+ for(let first_expected of [["First", "First"],
+ ["First Middle", "First Middle"],
+ ["F. R. S.", "F. R. S."],
+ ["F.R.S.", "F. R. S."],
+ ["F R S", "F. R. S."],
+ ["FRS", "F. R. S."]]) {
+ let [first, expected] = first_expected;
+ let str = useComma ? "Last, "+first : first+" Last";
+ let author = Zotero.Utilities.cleanAuthor(str, "author", useComma);
+ assert.equal(author.firstName, expected);
+ assert.equal(author.lastName, "Last");
+ }
+ }
+ });
+ });
+});