www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

translatorTester.js (23509B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2009 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23     ***** END LICENSE BLOCK *****
     24 */
     25 
     26 // Timeout for test to complete
     27 var TEST_RUN_TIMEOUT = 600000;
     28 var EXPORTED_SYMBOLS = ["Zotero_TranslatorTesters"];
     29 
     30 // For debugging specific translators by label
     31 var includeTranslators = [];
     32 
     33 try {
     34 	Zotero;
     35 } catch(e) {
     36 	var Zotero;
     37 }
     38 
     39 var Zotero_TranslatorTesters = new function() {
     40 	const TEST_TYPES = ["web", "import", "export", "search"];
     41 	var collectedResults = {};
     42 	
     43 	/**
     44 	 * Runs all tests
     45 	 */
     46 	this.runAllTests = function (numConcurrentTests, skipTranslators, writeDataCallback) {
     47 		var id = Math.random() * (100000000 - 1) + 1;
     48 		
     49 		waitForDialog();
     50 		
     51 		if(!Zotero) {
     52 			Zotero = Components.classes["@zotero.org/Zotero;1"]
     53 				.getService(Components.interfaces.nsISupports).wrappedJSObject;
     54 		}
     55 		
     56 		var testers = [];
     57 		var waitingForTranslators = TEST_TYPES.length;
     58 		for(var i=0; i<TEST_TYPES.length; i++) {
     59 			Zotero.Translators.getAllForType(TEST_TYPES[i], true).
     60 			then(new function() {
     61 				var type = TEST_TYPES[i];
     62 				return function(translators) {
     63 					try {
     64 						for(var i=0; i<translators.length; i++) {
     65 							if (includeTranslators.length
     66 									&& !includeTranslators.some(x => translators[i].label.includes(x))) continue;
     67 							if (skipTranslators && skipTranslators[translators[i].translatorID]) continue;
     68 							testers.push(new Zotero_TranslatorTester(translators[i], type));
     69 						};
     70 						
     71 						if(!(--waitingForTranslators)) {
     72 							runTesters(testers, numConcurrentTests, id, writeDataCallback);
     73 						}
     74 					} catch(e) {
     75 						Zotero.debug(e);
     76 						Zotero.logError(e);
     77 					}
     78 				};
     79 			});
     80 		};
     81 	};
     82 	
     83 	/**
     84 	 * Runs a specific set of tests
     85 	 */
     86 	function runTesters(testers, numConcurrentTests, id, writeDataCallback) {
     87 		var testersRunning = 0;
     88 		var results = []
     89 		
     90 		var testerDoneCallback = function(tester) {
     91 			try {
     92 				if(tester.pending.length) return;
     93 				
     94 				Zotero.debug("Done testing "+tester.translator.label);
     95 				
     96 				// Done translating, so serialize test results
     97 				testersRunning--;
     98 				let results = tester.serialize();
     99 				let last = !testers.length && !testersRunning;
    100 				collectData(id, results, last, writeDataCallback);
    101 				
    102 				if(testers.length) {
    103 					// Run next tester if one is available
    104 					runNextTester();
    105 				}
    106 			} catch(e) {
    107 				Zotero.debug(e);
    108 				Zotero.logError(e);
    109 			}
    110 		};
    111 		
    112 		var runNextTester = function() {
    113 			if (!testers.length) {
    114 				return;
    115 			}
    116 			testersRunning++;
    117 			Zotero.debug("Testing "+testers[0].translator.label);
    118 			testers.shift().runTests(testerDoneCallback);
    119 		};
    120 		
    121 		for(var i=0; i<numConcurrentTests; i++) {
    122 			runNextTester();
    123 		};
    124 	}
    125 	
    126 	function waitForDialog() {
    127 		Components.utils.import("resource://gre/modules/Services.jsm");
    128 		var loadobserver = function (ev) {
    129 			ev.originalTarget.removeEventListener("load", loadobserver, false);
    130 			if (ev.target.location == "chrome://global/content/commonDialog.xul") {
    131 				let win = ev.target.docShell.QueryInterface(Components.interfaces.nsIInterfaceRequestor)
    132 					.getInterface(Components.interfaces.nsIDOMWindow);
    133 				Zotero.debug("Closing rogue dialog box!\n\n" + win.document.documentElement.textContent, 2);
    134 				win.document.documentElement.getButton('accept').click();
    135 			}
    136 		};
    137 		var winobserver = {
    138 			observe: function (subject, topic, data) {
    139 				if (topic != "domwindowopened") return;
    140 				var win = subject.QueryInterface(Components.interfaces.nsIDOMWindow);
    141 				win.addEventListener("load", loadobserver, false);
    142 			}
    143 		};
    144 		Services.ww.registerNotification(winobserver);
    145 	}
    146 	
    147 	function collectData(id, results, last, writeDataCallback) {
    148 		if (!collectedResults[id]) {
    149 			collectedResults[id] = [];
    150 		}
    151 		collectedResults[id].push(results);
    152 		
    153 		//
    154 		// TODO: Only do the below every x collections, or if last == true
    155 		//
    156 		// Sort results
    157 		if ("getLocaleCollation" in Zotero) {
    158 			let collation = Zotero.getLocaleCollation();
    159 			var strcmp = function (a, b) {
    160 				return collation.compareString(1, a, b);
    161 			};
    162 		}
    163 		else {
    164 			var strcmp = function (a, b) {
    165 				return a.toLowerCase().localeCompare(b.toLowerCase());
    166 			};
    167 		}
    168 		collectedResults[id].sort(function (a, b) {
    169 			if (a.type !== b.type) {
    170 				return TEST_TYPES.indexOf(a.type) - TEST_TYPES.indexOf(b.type);
    171 			}
    172 			return strcmp(a.label, b.label);
    173 		});
    174 		
    175 		writeDataCallback(collectedResults[id], last);
    176 	}
    177 }
    178 
    179 /**
    180  * A tool to run unit tests for a given translator
    181  *
    182  * @property {Array} tests All tests for this translator
    183  * @property {Array} pending All tests for this translator
    184  * @property {Array} succeeded All tests for this translator
    185  * @property {Array} failed All tests for this translator
    186  * @property {Array} unknown All tests for this translator
    187  * @constructor
    188  * @param {Zotero.Translator[]} translator The translator for which to run tests
    189  * @param {String} type The type of tests to run (web, import, export, or search)
    190  * @param {Function} [debugCallback] A function to call to write debug output. If not present,
    191  *     Zotero.debug will be used.
    192  */
    193 var Zotero_TranslatorTester = function(translator, type, debugCallback) {
    194 	this.type = type;
    195 	this.translator = translator;
    196 	this.output = "";
    197 	this.isSupported = this.translator.runMode === Zotero.Translator.RUN_MODE_IN_BROWSER;
    198 	this.translator.runMode = Zotero.Translator.RUN_MODE_IN_BROWSER;
    199 	
    200 	this.tests = [];
    201 	this.pending = [];
    202 	this.succeeded = [];
    203 	this.failed = [];
    204 	this.unknown = [];
    205 	
    206 	var me = this;
    207 	this._debug = function(obj, a, b) {
    208 		me.output += me.output ? "\n"+a : a;
    209 		if(debugCallback) {
    210 			debugCallback(me, a, b);
    211 		} else {
    212 			Zotero.debug(a, b);
    213 		}
    214 	};
    215 	
    216 	var code = translator.code;
    217 	var testStart = code.indexOf("/** BEGIN TEST CASES **/");
    218 	var testEnd   = code.indexOf("/** END TEST CASES **/"); 
    219 	if (testStart !== -1 && testEnd !== -1) {
    220 		var test = code.substring(testStart + 24, testEnd)
    221 			.replace(/^[\s\r\n]*var testCases = /, '')
    222 			.replace(/;[\s\r\n]*$/, '');
    223 		try {
    224 			var testObject = JSON.parse(test);
    225 		} catch (e) {
    226 			Zotero.logError(e+" parsing tests for "+translator.label);
    227 			return;
    228 		}
    229 		
    230 		for(var i=0, n=testObject.length; i<n; i++) {
    231 			if(testObject[i].type === type) {
    232 				this.tests.push(testObject[i]);
    233 				this.pending.push(testObject[i]);
    234 			}
    235 		}
    236 	}
    237 };
    238 
    239 Zotero_TranslatorTester.DEFER_DELAY = 20000; // Delay for deferred tests
    240 
    241 /**
    242  * Removes document objects, which contain cyclic references, and other fields to be ignored from items
    243  * @param {Object} Item, in the format returned by Zotero.Item.serialize()
    244  */
    245 Zotero_TranslatorTester._sanitizeItem = function(item, testItem, keepValidFields) {
    246 	// remove cyclic references 
    247 	if(item.attachments && item.attachments.length) {
    248 		// don't actually test URI equality
    249 		for (var i=0; i<item.attachments.length; i++) {
    250 			var attachment = item.attachments[i];
    251 			if(attachment.document) {
    252 				delete attachment.document;
    253 			}
    254 			
    255 			if(attachment.url) {
    256 				delete attachment.url;
    257 			}
    258 			
    259 			if(attachment.complete) {
    260 				delete attachment.complete;
    261 			}
    262 		}
    263 	}
    264 	
    265 	// try to convert to JSON and back to get rid of undesirable undeletable elements; this may fail
    266 	try {
    267 		item = JSON.parse(JSON.stringify(item));
    268 	} catch(e) {};
    269 	
    270 	// remove fields that don't exist or aren't valid for this item type, and normalize base fields
    271 	// to fields specific to this item
    272 	var fieldID, itemFieldID,
    273 		typeID = Zotero.ItemTypes.getID(item.itemType);
    274 	const skipFields = ["note", "notes", "itemID", "attachments", "tags", "seeAlso",
    275 						"itemType", "complete", "creators"];
    276 	for(var field in item) {
    277 		if(skipFields.indexOf(field) !== -1) {
    278 			continue;
    279 		}
    280 		
    281 		if((!item[field] && (!testItem || item[field] !== false))
    282 				|| !(fieldID = Zotero.ItemFields.getID(field))) {
    283 			delete item[field];
    284 			continue;
    285 		}
    286 		
    287 		if(itemFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(typeID, fieldID)) {
    288 			var value = item[field];
    289 			delete item[field];		
    290 			item[Zotero.ItemFields.getName(itemFieldID)] = value;
    291 			continue;
    292 		}
    293 		
    294 		if(!Zotero.ItemFields.isValidForType(fieldID, typeID)) {
    295 			delete item[field];
    296 		}
    297 	}
    298 	
    299 	// remove fields to be ignored
    300 	if(!keepValidFields && "accessDate" in item) delete item.accessDate;
    301 	
    302 	// Sort tags
    303 	if (item.tags && Array.isArray(item.tags)) {
    304 		// Normalize tags -- necessary until tests are updated for 5.0
    305 		if (testItem) {
    306 			item.tags = Zotero.Translate.Base.prototype._cleanTags(item.tags);
    307 		}
    308 		item.tags.sort((a, b) => {
    309 			if (a.tag < b.tag) return -1;
    310 			if (b.tag < a.tag) return 1;
    311 			return 0;
    312 		});
    313 	}
    314 	
    315 	return item;
    316 };
    317 /**
    318  * Serializes translator tester results to JSON
    319  */
    320 Zotero_TranslatorTester.prototype.serialize = function() {
    321 	return {
    322 		"translatorID":this.translator.translatorID,
    323 		"type":this.type,
    324 		"output":this.output,
    325 		"label":this.translator.label,
    326 		"isSupported":this.isSupported,
    327 		"pending":this.pending,
    328 		"failed":this.failed,
    329 		"succeeded":this.succeeded,
    330 		"unknown":this.unknown
    331 	};
    332 };
    333 
    334 /**
    335  * Sets tests for this translatorTester
    336  */
    337 Zotero_TranslatorTester.prototype.setTests = function(tests) {
    338 	this.tests = tests.slice(0);
    339 	this.pending = tests.slice(0);
    340 	this.succeeded = [];
    341 	this.failed = [];
    342 	this.unknown = [];
    343 };
    344 
    345 /**
    346  * Executes tests for this translator
    347  * @param {Function} testDoneCallback A callback to be executed each time a test is complete
    348  */
    349 Zotero_TranslatorTester.prototype.runTests = function(testDoneCallback, recursiveRun) {
    350 	if(!recursiveRun) {
    351 		var w = (this.pending.length === 1) ? "test" : "tests"; 
    352 		this._debug(this, "TranslatorTester: Running "+this.pending.length+" "+w+" for "+this.translator.label);
    353 	}
    354 	
    355 	if(!this.pending.length) {
    356 		// always call testDoneCallback once if there are no tests
    357 		if(!recursiveRun && testDoneCallback) testDoneCallback(this, null, "unknown", "No tests present\n");
    358 		return;
    359 	}
    360 	
    361 	this._runTestsRecursively(testDoneCallback);
    362 };
    363 
    364 /**
    365  * Executes tests for this translator, without checks or a debug message
    366  * @param {Function} testDoneCallback A callback to be executed each time a test is complete
    367  */
    368 Zotero_TranslatorTester.prototype._runTestsRecursively = function(testDoneCallback) {
    369 	var test = this.pending.shift();
    370 	var testNumber = this.tests.length-this.pending.length;
    371 	var me = this;
    372 	
    373 	this._debug(this, "TranslatorTester: Running "+this.translator.label+" Test "+testNumber);
    374 	
    375 	var executedCallback = false;
    376 	var callback = function(obj, test, status, message) {
    377 		if(executedCallback) return;
    378 		executedCallback = true;
    379 		
    380 		me._debug(this, "TranslatorTester: "+me.translator.label+" Test "+testNumber+": "+status+" ("+message+")");
    381 		me[status].push(test);
    382 		test.message = message;
    383 		if(testDoneCallback) testDoneCallback(me, test, status, message);
    384 		me.runTests(testDoneCallback, true);
    385 	};
    386 	
    387 	if(this.type === "web") {
    388 		this.fetchPageAndRunTest(test, callback);
    389 	} else {
    390 		(Zotero.setTimeout ? Zotero : window).setTimeout(function() {
    391 			me.runTest(test, null, callback);
    392 		}, 0);
    393 	}
    394 	
    395 	(Zotero.setTimeout ? Zotero : window).setTimeout(function() {
    396 		callback(me, test, "failed", "Test timed out after "+TEST_RUN_TIMEOUT/1000+" seconds");
    397 	}, TEST_RUN_TIMEOUT);
    398 };
    399 
    400 /**
    401  * Fetches the page for a given test and runs it
    402  *
    403  * This function is only applicable in Firefox; it is overridden in translator_global.js in Chrome
    404  * and Safari.
    405  *
    406  * @param {Object} test - Test to execute
    407  * @param {Function} testDoneCallback - A callback to be executed when test is complete
    408  */
    409 Zotero_TranslatorTester.prototype.fetchPageAndRunTest = function (test, testDoneCallback) {
    410 	Zotero.HTTP.processDocuments(
    411 		test.url,
    412 		(doc) => {
    413 			this.runTest(test, doc, function (obj, test, status, message) {
    414 				testDoneCallback(obj, test, status, message);
    415 			});
    416 		}
    417 	)
    418 	.catch(function (e) {
    419 		testDoneCallback(this, test, "failed", "Translation failed to initialize: " + e);
    420 	}.bind(this))
    421 };
    422 
    423 /**
    424  * Executes a test for a translator, given the document to test upon
    425  * @param {Object} test Test to execute
    426  * @param {Document} data DOM document to test against
    427  * @param {Function} testDoneCallback A callback to be executed when test is complete
    428  */
    429 Zotero_TranslatorTester.prototype.runTest = function(test, doc, testDoneCallback) {
    430 	this._debug(this, "TranslatorTester: Translating"+(test.url ? " "+test.url : ""));
    431 	
    432 	var me = this;
    433 	var translate = Zotero.Translate.newInstance(this.type);
    434 	
    435 	if(this.type === "web") {
    436 		translate.setDocument(doc);
    437 	} else if(this.type === "import") {
    438 		translate.setString(test.input);
    439 	} else if(this.type === "search") {
    440 		translate.setSearch(test.input);
    441 	}
    442 	
    443 	translate.setHandler("translators", function(obj, translators) {
    444 		me._runTestTranslate(translate, translators, test, testDoneCallback);
    445 	});
    446 	translate.setHandler("debug", this._debug);
    447 	var errorReturned;
    448 	translate.setHandler("error", function(obj, err) {
    449 		errorReturned = err;
    450 	});
    451 	translate.setHandler("done", function(obj, returnValue) {
    452 		me._checkResult(test, obj, returnValue, errorReturned, testDoneCallback);
    453 	});
    454 	var selectCalled = false;
    455 	translate.setHandler("select", function(obj, items, callback) {
    456 		if(test.items !== "multiple" && test.items.length <= 1) {
    457 			testDoneCallback(me, test, "failed", "Zotero.selectItems() called, but only one item defined in test");
    458 			callback({});
    459 			return;
    460 		} else if(selectCalled) {
    461 			testDoneCallback(me, test, "failed", "Zotero.selectItems() called multiple times");
    462 			callback({});
    463 			return;
    464 		}
    465 		
    466 		selectCalled = true;
    467 		var newItems = {};
    468 		var haveItems = false;
    469 		for(var i in items) {
    470 			if(items[i] && typeof(items[i]) == "object" && items[i].title !== undefined) {
    471 				newItems[i] = items[i].title;
    472 			} else {
    473 				newItems[i] = items[i];
    474 			}
    475 			haveItems = true;
    476 			
    477 			// only save one item if "items":"multiple" (as opposed to an array of items)
    478 			if(test.items === "multiple") break;
    479 		}
    480 		
    481 		if(!haveItems) {
    482 			testDoneCallback(me, test, "failed", "No items defined");
    483 			callback({});
    484 		}
    485 		
    486 		callback(newItems);
    487 	});
    488 	translate.capitalizeTitles = false;
    489 	
    490 	// internal hack to call detect on this translator
    491 	translate._potentialTranslators = [this.translator];
    492 	translate._foundTranslators = [];
    493 	translate._currentState = "detect";
    494 	translate._detect();
    495 }
    496 
    497 /**
    498  * Runs translation for a translator, given a document to test against
    499  */
    500 Zotero_TranslatorTester.prototype._runTestTranslate = function(translate, translators, test, testDoneCallback) {
    501 	if(!translators.length) {
    502 		testDoneCallback(this, test, "failed", "Detection failed");
    503 		return;
    504 	} else if(this.type === "web" && translators[0].itemType !== Zotero.Translator.RUN_MODE_ZOTERO_SERVER
    505 			&& (translators[0].itemType !== "multiple" && test.items.length > 1 ||
    506 			test.items.length === 1 && translators[0].itemType !== test.items[0].itemType)) {
    507 				// this handles "items":"multiple" too, since the string has length 8
    508 		testDoneCallback(this, test, "failed", "Detection returned wrong item type");
    509 		return;
    510 	}
    511 	
    512 	translate.setTranslator(this.translator);
    513 	translate.translate({
    514 		libraryID: false
    515 	});
    516 };
    517 
    518 /**
    519  * Checks whether the results of translation match what is expected by the test
    520  * @param {Object} test Test that was executed
    521  * @param {Zotero.Translate} translate The Zotero.Translate instance
    522  * @param {Boolean} returnValue Whether translation completed successfully
    523  * @param {Error} error Error code, if one was specified
    524  * @param {Function} testDoneCallback A callback to be executed when test is complete
    525  */
    526 Zotero_TranslatorTester.prototype._checkResult = function(test, translate, returnValue, error, testDoneCallback) {
    527 	if(error) {
    528 		var errorString = "Translation failed: "+error.toString();
    529 		if(typeof error === "object") {
    530 			for(var i in error) {
    531 				if(typeof(error[i]) != "object") {
    532 					errorString += "\n"+i+' => '+error[i];
    533 				}
    534 			}
    535 		}
    536 		testDoneCallback(this, test, "failed", errorString);
    537 		return;
    538 	}
    539 	
    540 	if(!returnValue) {
    541 		testDoneCallback(this, test, "failed", "Translation failed; examine debug output for errors");
    542 		return;
    543 	}
    544 	
    545 	if(!translate.newItems.length) {
    546 		testDoneCallback(this, test, "failed", "Translation failed: no items returned");
    547 		return;
    548 	}
    549 	
    550 	if(test.items !== "multiple") {
    551 		if(translate.newItems.length !== test.items.length) {
    552 			testDoneCallback(this, test, "unknown", "Expected "+test.items.length+" items; got "+translate.newItems.length);
    553 			return;
    554 		}
    555 		
    556 		for(var i=0, n=test.items.length; i<n; i++) {
    557 			var testItem = Zotero_TranslatorTester._sanitizeItem(test.items[i], true);
    558 			var translatedItem = Zotero_TranslatorTester._sanitizeItem(translate.newItems[i]);
    559 			
    560 			if(!Zotero_TranslatorTester._compare(testItem, translatedItem)) {
    561 				// Show diff
    562 				this._debug(this, "TranslatorTester: Data mismatch detected:");
    563 				this._debug(this, Zotero_TranslatorTester._generateDiff(testItem, translatedItem));
    564 				
    565 				// Save items. This makes it easier to correct tests automatically.
    566 				var m = translate.newItems.length;
    567 				test.itemsReturned = new Array(m);
    568 				for(var j=0; j<m; j++) {
    569 					test.itemsReturned[j] = Zotero_TranslatorTester._sanitizeItem(translate.newItems[i]);
    570 				}
    571 				
    572 				testDoneCallback(this, test, "unknown", "Item "+i+" does not match");
    573 				return;
    574 			}
    575 		}
    576 	}
    577 	
    578 	testDoneCallback(this, test, "succeeded", "Test succeeded");
    579 };
    580 
    581 /**
    582  * Creates a new test for a document
    583  * @param {Document} doc DOM document to test against
    584  * @param {Function} testReadyCallback A callback to be passed test (as object) when complete
    585  */
    586 Zotero_TranslatorTester.prototype.newTest = function(doc, testReadyCallback) {
    587 	// keeps track of whether select was called
    588 	var multipleMode = false;
    589 	
    590 	var me = this;
    591 	var translate = Zotero.Translate.newInstance(this.type);
    592 	translate.setDocument(doc);
    593 	translate.setTranslator(this.translator);
    594 	translate.setHandler("debug", this._debug);
    595 	translate.setHandler("select", function(obj, items, callback) {
    596 		multipleMode = true;
    597 		
    598 		var newItems = {};
    599 		for(var i in items) {
    600 			if(items[i] && typeof(items[i]) == "object" && items[i].title !== undefined) {
    601 				newItems[i] = items[i].title;
    602 			} else {
    603 				newItems[i] = items[i];
    604 			}
    605 			break;
    606 		}
    607 		
    608 		callback(newItems);
    609 	});
    610 	translate.setHandler("done", function(obj, returnValue) { me._createTest(obj, multipleMode, returnValue, testReadyCallback) });
    611 	translate.capitalizeTitles = false;
    612 	translate.translate({
    613 		libraryID: false
    614 	});
    615 };
    616 
    617 /**
    618  * Creates a new test for a document
    619  * @param {Zotero.Translate} translate The Zotero.Translate instance
    620  * @param {Function} testDoneCallback A callback to be passed test (as object) when complete
    621  */
    622 Zotero_TranslatorTester.prototype._createTest = function(translate, multipleMode, returnValue, testReadyCallback) {
    623 	if(!returnValue) {
    624 		testReadyCallback(returnValue);
    625 		return;
    626 	}
    627 	
    628 	if(!translate.newItems.length) {
    629 		testReadyCallback(false);
    630 		return;
    631 	}
    632 	
    633 	if(multipleMode) {
    634 		var items = "multiple";
    635 	} else {
    636 		for(var i=0, n=translate.newItems.length; i<n; i++) {
    637 			Zotero_TranslatorTester._sanitizeItem(translate.newItems[i]);
    638 		}
    639 		var items = translate.newItems;
    640 	}
    641 	
    642 	testReadyCallback(this, {"type":this.type, "url":translate.document.location.href,
    643 		"items":items});
    644 };
    645 
    646 
    647 /**
    648  * Compare items or sets thereof
    649  */
    650 Zotero_TranslatorTester._compare = function(a, b) {
    651 	// If a is false, comparisons always succeed. This allows us to explicitly set that
    652 	// certain properties are allowed.
    653 	if(a === false) return true;
    654 	
    655 	if(((typeof a === "object" && a !== null) || typeof a === "function")
    656 			&& ((typeof a === "object" && b !== null) || typeof b === "function")) {
    657 		if((Object.prototype.toString.apply(a) === "[object Array]")
    658 				!== (Object.prototype.toString.apply(b) === "[object Array]")) {
    659 			return false;
    660 		}
    661 		for(var key in a) {
    662 			if(!a.hasOwnProperty(key)) continue;
    663 			if(a[key] !== false && !b.hasOwnProperty(key)) return false;
    664 			if(!Zotero_TranslatorTester._compare(a[key], b[key])) return false;
    665 		}
    666 		for(var key in b) {
    667 			if(!b.hasOwnProperty(key)) continue;
    668 			if(!a.hasOwnProperty(key)) return false;
    669 		}
    670 		return true;
    671 	} else if(typeof a === "string" && typeof b === "string") {
    672 		// Ignore whitespace mismatches on strings
    673 		return a === b || Zotero.Utilities.trimInternal(a) === Zotero.Utilities.trimInternal(b);
    674 	}
    675 	return a === b;
    676 };
    677 
    678 /**
    679  * Generate a diff of items
    680  */
    681 Zotero_TranslatorTester._generateDiff = new function() {
    682 	function show(a, action, prefix, indent) {
    683 		if((typeof a === "object" && a !== null) || typeof a === "function") {
    684 			var isArray = Object.prototype.toString.apply(a) === "[object Array]",
    685 				startBrace = (isArray ? "[" : "{"),
    686 				endBrace = (isArray ? "]" : "}"),
    687 				changes = "",
    688 				haveKeys = false;
    689 			
    690 			for(var key in a) {
    691 				if(!a.hasOwnProperty(key)) continue;
    692 				
    693 				haveKeys = true;
    694 				changes += show(a[key], action,
    695 					isArray ? "" : JSON.stringify(key)+": ", indent+"  ");
    696 			}
    697 			
    698 			if(haveKeys) {
    699 				return action+" "+indent+prefix+startBrace+"\n"+
    700 					changes+action+" "+indent+endBrace+"\n";
    701 			}
    702 			return action+" "+indent+prefix+startBrace+endBrace+"\n";
    703 		}
    704 		
    705 		return action+" "+indent+prefix+JSON.stringify(a)+"\n";
    706 	}
    707 	
    708 	function compare(a, b, prefix, indent) {
    709 		if(!prefix) prefix = "";
    710 		if(!indent) indent = "";
    711 		
    712 		if(((typeof a === "object" && a !== null) || typeof a === "function")
    713 				&& ((typeof b === "object" && b !== null) || typeof b === "function")) {
    714 			var aIsArray = Object.prototype.toString.apply(a) === "[object Array]",
    715 				bIsArray = Object.prototype.toString.apply(b) === "[object Array]";
    716 			if(aIsArray === bIsArray) {
    717 				var startBrace = (aIsArray ? "[" : "{"),
    718 					endBrace = (aIsArray ? "]" : "}"),
    719 					changes = "",
    720 					haveKeys = false;
    721 				
    722 				for(var key in a) {
    723 					if(!a.hasOwnProperty(key)) continue;
    724 					
    725 					haveKeys = true;
    726 					var keyPrefix = aIsArray ? "" : JSON.stringify(key)+": ";
    727 					if(b.hasOwnProperty(key)) {
    728 						changes += compare(a[key], b[key], keyPrefix, indent+"  ");
    729 					} else {
    730 						changes += show(a[key], "-", keyPrefix, indent+"  ");
    731 					}
    732 				}
    733 				for(var key in b) {
    734 					if(!b.hasOwnProperty(key)) continue;
    735 					
    736 					haveKeys = true;
    737 					if(!a.hasOwnProperty(key)) {
    738 						var keyPrefix = aIsArray ? "" : JSON.stringify(key)+": ";
    739 						changes += show(b[key], "+", keyPrefix, indent+"  ");
    740 					}
    741 				}
    742 				
    743 				if(haveKeys) {
    744 					return "  "+indent+prefix+startBrace+"\n"+
    745 						changes+"  "+indent+(aIsArray ? "]" : "}")+"\n";
    746 				}
    747 				return "  "+indent+prefix+startBrace+endBrace+"\n";
    748 			}
    749 		}
    750 		
    751 		if(a === b) {
    752 			return show(a, " ", prefix, indent);
    753 		}
    754 		return show(a, "-", prefix, indent)+show(b, "+", prefix, indent);
    755 	}
    756 	
    757 	return function(a, b) {
    758 		// Remove last newline
    759 		var txt = compare(a, b);
    760 		return txt.substr(0, txt.length-1);
    761 	};
    762 };