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