├── .gitignore ├── samples ├── actual.xml └── expected.xml ├── lib ├── normalize_newlines.js ├── collapse_spaces.js ├── node_types.js ├── reporters │ └── groupingReporter.js ├── revxpath.js ├── canonizer.js ├── compare.js └── collector.js ├── .travis.yml ├── index.js ├── CHANGELOG.md ├── package.json ├── LICENSE ├── test ├── test-reporter.js ├── test-normalize_newlines.js ├── test-revxpath.js ├── test-canonize.js ├── test-collector.js └── test-compare.js ├── bin └── domcompare └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | lcov.info 3 | *.seed 4 | *.log 5 | *.csv 6 | *.dat 7 | *.out 8 | *.pid 9 | *.gz 10 | 11 | pids 12 | logs 13 | results 14 | 15 | .idea 16 | node_modules 17 | npm-debug.log 18 | -------------------------------------------------------------------------------- /samples/actual.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | text content 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /samples/expected.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | text content 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/normalize_newlines.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | "use strict"; 4 | 5 | module.exports = function normalizeNewlines(str) { 6 | // First replace all CR+LF newlines then replace CR newlines 7 | return str.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); 8 | }; 9 | 10 | })(); 11 | -------------------------------------------------------------------------------- /lib/collapse_spaces.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | "use strict"; 4 | 5 | module.exports = function collapseSpaces(str) { 6 | // Replace all whitespace with the space character and then collapse all white space to one space character 7 | return str.replace(/\s/g, ' ').replace(/\s\s+/g, ' '); 8 | }; 9 | 10 | })(); 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "12" 5 | - "11" 6 | - "10" 7 | - "9" 8 | - "8" 9 | - "7" 10 | - "6" 11 | - "5.1" 12 | - "5.0" 13 | - "4.2" 14 | - "4.1" 15 | - "4.0" 16 | 17 | script: npm test && npm run-script test-cov && (cat lcov.info | ./node_modules/.bin/coveralls) && (rm -rf lib-cov lcov.info) 18 | -------------------------------------------------------------------------------- /lib/node_types.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | "use strict"; 4 | 5 | module.exports = { 6 | ELEMENT_NODE: 1, 7 | ATTRIBUTE_NODE: 2, 8 | TEXT_NODE: 3, 9 | CDATA_SECTION_NODE: 4, 10 | ENTITY_REFERENCE_NODE: 5, 11 | ENTITY_NODE: 6, 12 | PROCESSING_INSTRUCTION_NODE: 7, 13 | COMMENT_NODE: 8, 14 | DOCUMENT_NODE: 9, 15 | DOCUMENT_TYPE_NODE: 10, 16 | DOCUMENT_FRAGMENT_NODE: 11, 17 | NOTATION_NODE: 12 18 | }; 19 | 20 | })(); -------------------------------------------------------------------------------- /lib/reporters/groupingReporter.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | "use strict"; 4 | 5 | module.exports = { 6 | report: function(res) { 7 | var _res = this.getDifferences(res); 8 | return Object.keys(_res).map(function(path){ 9 | return [path, "\n\t", _res[path].join('\n\t')].join(''); 10 | }.bind(this)).join('\n'); 11 | }, 12 | getDifferences: function(res) { 13 | var _res = {}; 14 | res.getDifferences().forEach(function(f){ 15 | (_res[f.node] = (_res[f.node] || [])).push(f.message); 16 | }.bind(this)); 17 | return _res; 18 | } 19 | }; 20 | 21 | })(); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | "use strict"; 4 | 5 | var libPrefix = process.env.COVER ? './lib-cov' : './lib'; 6 | 7 | module.exports = process.browser ? { 8 | compare: require('./lib/compare'), 9 | XMLSerializer: require('./lib/canonizer'), 10 | revXPath: require('./lib/revxpath'), 11 | GroupingReporter: require('./lib/reporters/groupingReporter.js') 12 | } : 13 | { 14 | compare: require(libPrefix + '/compare'), 15 | XMLSerializer: require(libPrefix + '/canonizer'), 16 | revXPath: require(libPrefix + '/revxpath'), 17 | GroupingReporter: require(libPrefix + '/reporters/groupingReporter.js') 18 | }; 19 | 20 | })(); -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.6.0, 14.10.2019 4 | 5 | * Fixed [#43](https://github.com/Olegas/dom-compare/issues/43). Thanks to @Sh33pman 6 | 7 | ## v0.5.0, 21.09.2019 8 | 9 | * Added `normalizeNewlines` option. See docs for details. Thanks to @jordandh 10 | 11 | ## v0.4.0, 05.09.2019 12 | 13 | * Added `collapseSpaces` option. See docs for details. Thanks to @jordandh 14 | 15 | ## v0.3.1, 01.05.2017 16 | 17 | * Added support for document fragments. Closing #26 18 | 19 | ## v0.3.0, 27.04.2017 20 | 21 | * **BREAKING CHANGES**. Fixed a typo in groupingReporter.getDifferences method. Thanks to @jonathanp 22 | 23 | ## v0.2.1, 25.04.2015 24 | 25 | * Browserify support 26 | * Fixed dependencies versions to make all test pass 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dom-compare", 3 | "description": "Library to compare two DOM trees", 4 | "version": "0.6.0", 5 | "author": "Oleg Elifantiev ", 6 | "contributors": [], 7 | "keywords": [ 8 | "dom", 9 | "comparison" 10 | ], 11 | "bin": { 12 | "domcompare": "bin/domcompare" 13 | }, 14 | "dependencies": { 15 | "argparse": "^1.0.10", 16 | "colors": "0.6.2", 17 | "xmldom": "0.1.19" 18 | }, 19 | "devDependencies": { 20 | "coveralls": "^3.0.2", 21 | "istanbul": "^0.4.5", 22 | "mocha": "^5.2.0", 23 | "mocha-istanbul": "0.2.0" 24 | }, 25 | "scripts": { 26 | "instrument": "istanbul instrument --output lib-cov --no-compact --variable global.__coverage__ lib", 27 | "test-cov": "npm run-script instrument && COVER=1 ISTANBUL_REPORTERS=lcovonly mocha -R mocha-istanbul", 28 | "test": "mocha -R spec" 29 | }, 30 | "repository": "git@github.com:Olegas/dom-compare.git", 31 | "engines": { 32 | "node": "*" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Oleg Elifantiev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/test-reporter.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var xmldom = require("xmldom"); 3 | var parser = new xmldom.DOMParser(); 4 | var domCompare = require("../"); 5 | var compare = domCompare.compare; 6 | var reporter = domCompare.GroupingReporter; 7 | 8 | describe("Differences reporting", function(){ 9 | 10 | describe("Grouping reporter", function(){ 11 | 12 | it("grouping differences by source node XPath", function(){ 13 | 14 | var doc = parser.parseFromString(""); 15 | var doc2 = parser.parseFromString(""); 16 | 17 | var failures = reporter.getDifferences(compare(doc, doc2)); 18 | 19 | assert.equal(3, Object.keys(failures).length); 20 | assert.equal(3, failures['/doc'].length); 21 | assert.equal(2, failures['/doc/node1'].length); 22 | assert.equal(1, failures['/doc/node2'].length); 23 | 24 | // check for results doplication 25 | failures = reporter.getDifferences(compare(doc, doc2)); 26 | 27 | assert.equal(3, Object.keys(failures).length); 28 | assert.equal(3, failures['/doc'].length); 29 | assert.equal(2, failures['/doc/node1'].length); 30 | assert.equal(1, failures['/doc/node2'].length); 31 | 32 | }); 33 | 34 | }); 35 | 36 | }); -------------------------------------------------------------------------------- /test/test-normalize_newlines.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var normalizeNewlines = require('../lib/normalize_newlines'); 3 | 4 | describe('normalize_newlines', function () { 5 | it('should transform CR+LF to LF', function() { 6 | var input = '\r\n'; 7 | var expected = '\n'; 8 | assert.equal(normalizeNewlines(input), expected); 9 | }); 10 | 11 | it('should transform CR to LF', function() { 12 | var input = '\r'; 13 | var expected = '\n'; 14 | assert.equal(normalizeNewlines(input), expected); 15 | }); 16 | 17 | it('should transform mixed CR+LF and LF to LF', function() { 18 | var input = '\n\r\n\n\r\n\n'; 19 | var expected = '\n\n\n\n\n'; 20 | assert.equal(normalizeNewlines(input), expected); 21 | }); 22 | 23 | it('should transform mixed CR+LF and CR to LF', function() { 24 | var input = '\r\n\r\n\r \r\n\r'; 25 | var expected = '\n\n\n \n\n'; 26 | assert.equal(normalizeNewlines(input), expected); 27 | }); 28 | 29 | it('should transform mixed CR and LF to LF', function() { 30 | var input = '\n\n\r \n\r'; 31 | var expected = '\n\n\n \n\n'; 32 | assert.equal(normalizeNewlines(input), expected); 33 | }); 34 | 35 | it('should transform mixed CR+LF and CR and LF to LF', function() { 36 | var input = '\n\r\n\r\n\n\r \n\r\n\r'; 37 | var expected = '\n\n\n\n\n \n\n\n'; 38 | assert.equal(normalizeNewlines(input), expected); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /lib/revxpath.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | "use strict"; 4 | 5 | var type = require('./node_types'); 6 | 7 | function _describeNode(node) { 8 | var parent = node.parentNode, 9 | myName = node.nodeName, 10 | sameSiblings, 11 | i, l; 12 | 13 | // Find all siblings that have the same name. 14 | if (parent && parent.childNodes && parent.childNodes.length) { 15 | sameSiblings = []; 16 | for(i = 0, l = parent.childNodes.length; i < l; i++) { 17 | if (parent.childNodes[i].nodeName === myName) { 18 | sameSiblings.push(parent.childNodes[i]); 19 | } 20 | } 21 | } 22 | 23 | if(node.nodeType == type.DOCUMENT_NODE) 24 | return ""; 25 | 26 | if(sameSiblings && sameSiblings.length > 1) { 27 | if(node.hasAttribute('id')) 28 | return myName + "[@id='" + node.getAttribute('id') + "']"; 29 | for(i = 0, l = sameSiblings.length; i < l; i++) { 30 | if(sameSiblings[i] == node) 31 | return myName + "[" + (i + 1) + "]"; 32 | } 33 | throw new Error("Node is not found, but should be!"); 34 | } else 35 | return myName; 36 | } 37 | 38 | function _processNode(node, res) { 39 | 40 | res.unshift(_describeNode(node)); 41 | if(node.parentNode) 42 | _processNode(node.parentNode, res); 43 | 44 | } 45 | 46 | module.exports = function revXPath(node) { 47 | 48 | var res; 49 | 50 | if(!node) 51 | return ""; 52 | 53 | if(node.nodeType == type.DOCUMENT_NODE) 54 | return "/"; 55 | 56 | _processNode(node, res = []); 57 | return res.join('/'); 58 | 59 | }; 60 | 61 | 62 | })(); -------------------------------------------------------------------------------- /test/test-revxpath.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var xmldom = require("xmldom"); 3 | var parser = new xmldom.DOMParser(); 4 | var domCompare = require("../"); 5 | var revxpath = domCompare.revXPath; 6 | 7 | describe("Reverse XPath", function(){ 8 | 9 | it("Builds a node path by its name", function(){ 10 | 11 | var doc = parser.parseFromString(""); 12 | 13 | var path = revxpath(doc.getElementsByTagName('item')[0]); 14 | 15 | assert.equal("/doc/child/item", path); 16 | 17 | path = revxpath(doc.documentElement); 18 | 19 | assert.equal("/doc", path); 20 | 21 | path = revxpath(doc); 22 | 23 | assert.equal("/", path); 24 | 25 | }); 26 | 27 | describe("Multiple same named siblings", function(){ 28 | 29 | it("number is added to a node name", function(){ 30 | 31 | var doc = parser.parseFromString(""); 32 | 33 | var path = revxpath(doc.getElementsByTagName('item')[0]); 34 | 35 | assert.equal("/doc/child/item[1]", path); 36 | 37 | path = revxpath(doc.getElementsByTagName('item')[1]); 38 | 39 | assert.equal("/doc/child/item[2]", path); 40 | 41 | path = revxpath(doc.getElementsByTagName('inner')[0]); 42 | 43 | assert.equal("/doc/child/item[2]/inner", path); 44 | 45 | }); 46 | 47 | it("number is added correctly with repeating node names on multiple levels", function() { 48 | 49 | var doc = parser.parseFromString("
X
Y
Z
Some Text
Some Text Part 2
"); 50 | 51 | // Select
Some Text Part 2
and get the xpath 52 | var path = revxpath(doc.getElementsByTagName('div')[9]); 53 | 54 | assert.equal("/html/body/div[2]/section[2]/div", path); 55 | }); 56 | 57 | it("if ID attribute is present, it is used instead of number", function(){ 58 | 59 | var doc = parser.parseFromString(""); 60 | 61 | var path = revxpath(doc.getElementById('x')); 62 | 63 | assert.equal("/doc/child/item[@id='x']", path); 64 | 65 | path = revxpath(doc.getElementById('y')); 66 | 67 | assert.equal("/doc/child/item[@id='y']", path); 68 | 69 | }); 70 | }); 71 | 72 | }); -------------------------------------------------------------------------------- /test/test-canonize.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var xmldom = require("xmldom"); 3 | var parser = new xmldom.DOMParser(); 4 | var domCompare = require('../'); 5 | var canonize = (new (domCompare.XMLSerializer)()).serializeToString; 6 | 7 | describe("Canonize", function(){ 8 | it("can make a canonical form of a document", function(){ 9 | var doc = parser.parseFromString("" + 10 | ""); 11 | var docCanon = 12 | "\n" + 16 | " \n" + 20 | " \n" + 21 | " \n" + 22 | " \n" + 23 | " \n" + 24 | " \n" + 25 | " \n" + 26 | " \n" + 27 | " \n" + 28 | " \n" + 29 | " \n" + 30 | ""; 31 | assert.equal(docCanon, canonize(doc)); 32 | }); 33 | 34 | it("Default indent - 2 spaces for attributes and 4 spaces for nested elements", function(){ 35 | var doc = parser.parseFromString("text"); 36 | var docCanon = 37 | "\n" + 39 | " \n" + 40 | " text\n" + 41 | " \n" + 42 | ""; 43 | assert.equal(docCanon, canonize(doc)); 44 | }); 45 | 46 | it("Empty tags serialized in short form", function(){ 47 | var doc = parser.parseFromString(""); 48 | var docCanon = ""; 49 | assert.equal(docCanon, canonize(doc)); 50 | 51 | doc = parser.parseFromString(""); 52 | docCanon = 53 | ""; 55 | assert.equal(docCanon, canonize(doc)); 56 | }); 57 | 58 | it("Any leading/triling whitespace in text and comment nodes is trimmed", function(){ 59 | var doc = parser.parseFromString(" test aaa "); 60 | var docCanon = 61 | "\n" + 62 | " test\n" + 63 | " \n" + 64 | " aaa\n" + 65 | " \n" + 66 | " \n" + 67 | ""; 68 | assert.equal(docCanon, canonize(doc)); 69 | }); 70 | 71 | it("Attribute values serialized in double quotes", function(){ 72 | var doc = parser.parseFromString(""); 73 | var docCanon = 74 | "\n" + 75 | " \n" + 78 | ""; 79 | assert.equal(docCanon, canonize(doc)); 80 | }); 81 | 82 | }); -------------------------------------------------------------------------------- /bin/domcompare: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | var domcompare = require('../'), 6 | colors = require('colors'), 7 | compare = domcompare.compare, 8 | reporter = domcompare.GroupingReporter, 9 | fs = require('fs'), 10 | ArgumentParser = require('argparse').ArgumentParser, 11 | version = require('../package.json').version, 12 | xmldom = require('xmldom'), 13 | path = require('path'), 14 | domparser = new (xmldom.DOMParser)(), 15 | args, dom1, dom2, f1ext, f2ext, mimeMap, result, argparser; 16 | 17 | var ERR_BAD_FILES = 254, 18 | ERR_FILES_DIFFER = 1, 19 | ERR_OK = 0; 20 | 21 | mimeMap = { 22 | '.html' : 'text/html', 23 | '.xml' : 'text/xml' 24 | } 25 | 26 | argparser = new ArgumentParser({ 27 | version: version, 28 | addHelp: true, 29 | description: 'domcompare - simple DOM comparison utility' 30 | }); 31 | 32 | argparser.addArgument([ '-s', '--stripspaces' ], { 33 | defaultValue: false, 34 | dest: 'stripSpaces', 35 | action: 'storeTrue', 36 | help: "Strip spaces when comparing strings (exclude CDATA nodes)" 37 | }); 38 | 39 | argparser.addArgument([ '-l', '--collapsespaces' ], { 40 | defaultValue: false, 41 | dest: 'collapseSpaces', 42 | action: 'storeTrue', 43 | help: "Collapse spaces when comparing strings (exclude CDATA nodes)" 44 | }); 45 | 46 | argparser.addArgument([ '-n', '--normalizenewlines' ], { 47 | defaultValue: false, 48 | dest: 'normalizeNewlines', 49 | action: 'storeTrue', 50 | help: "Normalize newlines when comparing strings (include CDATA nodes)" 51 | }); 52 | 53 | argparser.addArgument([ '-c', '--comments' ], { 54 | defaultValue: false, 55 | dest: 'compareComments', 56 | action: 'storeTrue', 57 | help: "Compare comments" 58 | }); 59 | 60 | argparser.addArgument([ '-q', '--quiet' ], { 61 | defaultValue: false, 62 | dest: 'quiet', 63 | action: 'storeTrue', 64 | help: "Be quiet, don't output anything" 65 | }); 66 | 67 | argparser.addArgument([ 'FILE1' ], { 68 | help: 'Reference file' 69 | }); 70 | 71 | argparser.addArgument([ 'FILE2' ], { 72 | help: 'File to compare' 73 | }); 74 | 75 | 76 | 77 | args = argparser.parseArgs(); 78 | args.FILE1 = path.resolve(args.FILE1); 79 | args.FILE2 = path.resolve(args.FILE2); 80 | 81 | function getMime(f) { 82 | return mimeMap[path.extname(f)] || 'text/xml'; 83 | } 84 | 85 | function log(s) { 86 | if(!args.quiet) { 87 | console.log(s); 88 | } 89 | } 90 | 91 | if(fs.existsSync(args.FILE1) && fs.existsSync(args.FILE2)) { 92 | dom1 = domparser.parseFromString(fs.readFileSync(args.FILE1, 'utf8'), getMime(args.FILE1)); 93 | dom2 = domparser.parseFromString(fs.readFileSync(args.FILE2, 'utf8'), getMime(args.FILE2)); 94 | 95 | result = compare(dom1, dom2, args); 96 | 97 | if(result.getResult()) { 98 | log('Documents are equal'.green); 99 | process.exit(ERR_OK); 100 | } else { 101 | log('Documents are not equal'.red); 102 | log(reporter.report(result)); 103 | process.exit(ERR_FILES_DIFFER); 104 | } 105 | 106 | } else { 107 | log('Error'.red); 108 | process.exit(ERR_BAD_FILES); 109 | } 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /lib/canonizer.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | "use strict"; 4 | 5 | var c = require('./node_types'), spaces = ' '; 6 | 7 | 8 | function _sortAttributes(a, b) { 9 | return a.nodeName < b.nodeName ? -1 : 1; 10 | } 11 | 12 | function _canonizeNode(node, indent) { 13 | var i = new Array(indent + 1).join(spaces), hasChildren = node.childNodes.length > 0, ret; 14 | ret = i + "<" + node.nodeName + 15 | _canonizeAttributes(node.attributes, indent + 1); 16 | if(hasChildren) { 17 | ret += ">" + _canonizeNodeList(node.childNodes, indent + 2) + 18 | "\n" + i + ""; 19 | } else 20 | ret += " />"; 21 | return ret; 22 | } 23 | 24 | function _canonizeAttributes(attrs, indent) { 25 | 26 | var aList = [], ret = "", i = new Array(indent + 1); 27 | if (attrs && attrs.length) { 28 | 29 | Object.keys(attrs).map(function (i) { 30 | return parseInt(i, 10); 31 | }).filter(function (i) { 32 | return typeof(i) == 'number' && i >= 0; 33 | }).sort(function (a, b) { 34 | return a < b ? -1 : 1; 35 | }).forEach(function (k) { 36 | aList.push(attrs[k]); 37 | }); 38 | aList.sort(_sortAttributes); 39 | aList.forEach(function(a){ 40 | ret += "\n" + i.join(spaces) + a.nodeName + "=\"" + (a.nodeValue + "").replace(/"/g, '"') + "\""; 41 | }); 42 | } 43 | return ret; 44 | } 45 | 46 | function _canonizeNodeList(list, indent) { 47 | var ret = '', 48 | i, l; 49 | if(list){ 50 | l = list.length; 51 | for(i=0; i < l; i++) { 52 | ret += "\n" + canonize(list.item(i), indent); 53 | } 54 | } 55 | return ret; 56 | } 57 | 58 | function _canonizeText(nodeType, text, indent) { 59 | var ret = [], i = new Array(indent + 1).join(spaces); 60 | switch (nodeType) { 61 | case c.CDATA_SECTION_NODE: 62 | ret[0] = ""; 64 | break; 65 | case c.COMMENT_NODE: 66 | ret[0] = ""; 68 | break; 69 | case c.TEXT_NODE: 70 | break; 71 | } 72 | if(nodeType !== c.CDATA_SECTION_NODE) 73 | text = text.trim(); 74 | ret[1] = text; 75 | return i + ret.join(''); 76 | } 77 | 78 | function canonize(node, indent) { 79 | indent = indent || 0; 80 | switch (node.nodeType) { 81 | case c.DOCUMENT_NODE: 82 | return canonize(node.documentElement, indent); 83 | case c.ELEMENT_NODE: 84 | return _canonizeNode(node, indent); 85 | case c.CDATA_SECTION_NODE: 86 | // fallthrough 87 | case c.COMMENT_NODE: 88 | // fallthrough 89 | case c.TEXT_NODE: 90 | return _canonizeText(node.nodeType, node.nodeValue, indent); 91 | default: 92 | throw Error("Node type " + node.nodeType + " serialization is not implemented"); 93 | } 94 | 95 | } 96 | 97 | function XMLSerializer() {} 98 | 99 | XMLSerializer.prototype.serializeToString = function(doc) { 100 | return canonize(doc); 101 | }; 102 | 103 | module.exports = XMLSerializer; 104 | 105 | })(); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | dom-compare 2 | =========== 3 | 4 | [![Build Status](https://travis-ci.org/Olegas/dom-compare.png)](https://travis-ci.org/Olegas/dom-compare) 5 | [![Coverage Status](https://coveralls.io/repos/Olegas/dom-compare/badge.png?branch=master)](https://coveralls.io/r/Olegas/dom-compare) 6 | [![NPM version](https://badge.fury.io/js/dom-compare.png)](http://badge.fury.io/js/dom-compare) 7 | 8 | NodeJS module to compare two DOM-trees 9 | 10 | * [DOM Comparison](#dom-comparison) 11 | * [Comparison options](#comparison-options) 12 | * [Comments comparison](#comments-comparison) 13 | * [Whitespace comparison](#whitespace-comparison) 14 | * [Cli utility](#cli-utility) 15 | * [DOM Canonic Form](#dom-canonic-form) 16 | 17 | Works with Node.JS v0.10+ 18 | 19 | DOM Comparison 20 | -------------- 21 | 22 | Consider two documents. Expected: 23 | ```xml 24 | 25 | 26 | 27 | text content 28 | 29 | 30 | 31 | 32 | 33 | 34 | ``` 35 | and actual one: 36 | ```xml 37 | 38 | 39 | text content 40 | 41 | 42 | 43 | 44 | 45 | ``` 46 | 47 | One can compare them, get the result (is them equals, or not), and get extended report (why them are different). 48 | 49 | ```javascript 50 | var compare = require('dom-compare').compare, 51 | reporter = require('dom-compare').GroupingReporter, 52 | expected = ..., // expected DOM tree 53 | actual = ..., // actual one 54 | result, diff, groupedDiff; 55 | 56 | // compare to DOM trees, get a result object 57 | result = compare(expected, actual); 58 | 59 | // get comparison result 60 | console.log(result.getResult()); // false cause' trees are different 61 | 62 | // get all differences 63 | diff = result.getDifferences(); // array of diff-objects 64 | 65 | // differences, grouped by node XPath 66 | groupedDiff = reporter.getDifferences(result); // object, key - node XPATH, value - array of differences (strings) 67 | 68 | // string representation 69 | console.log(reporter.report(result)); 70 | ``` 71 | 72 | Diff-object has a following form: 73 | 74 | ```javascript 75 | { 76 | node: "/document/element", 77 | message: "Attribute 'attribute': expected value '10' instead of '100'"; 78 | } 79 | ``` 80 | 81 | By using `GroupingReporter` one can get a result of a following type 82 | 83 | ```javascript 84 | { 85 | '/document/element': [ 86 | "Attribute 'attribute': expected value '10' instead of '100'", 87 | "Extra attribute 'attributeX'" 88 | ] 89 | } 90 | ``` 91 | 92 | ### Comparison options 93 | 94 | Comparison function can take a third argument with options like this: 95 | ```javascript 96 | var options = { 97 | stripSpaces: true, 98 | compareComments: true, 99 | collapseSpaces: true, 100 | normalizeNewlines: true 101 | }; 102 | 103 | result = compare(expected, actual, options); 104 | ``` 105 | #### Comments comparison 106 | By default, all comments are ignored. Set `compareComments` options to `true` to compare them too. 107 | 108 | 109 | #### Whitespace comparison 110 | By default, all text nodes (text, CDATA, comments if enabled as mentioned above) compared with respect 111 | to leading, trailing, and internal whitespaces. 112 | Set `stripSpaces` option to `true` to automatically strip spaces in text and comment nodes. This option 113 | doesn't change the way CDATA sections is compared, they are always compared with respect to whitespaces. 114 | Set `collapseSpaces` option to `true` to automatically collapse all spaces in text and comment nodes. 115 | This option doesn't change the way CDATA sections is compared, they are always compared with respect to 116 | whitespaces. 117 | Set `normalizeNewlines` option to `true` to automatically normalize new line characters in text, 118 | comment, and CDATA nodes. 119 | 120 | ### Cli utility 121 | 122 | When installed globally with `npm install -g dom-compare` cli utility is available. 123 | See usage information and command-line options with `domcompare --help` 124 | 125 | You can try it on bundled samples: 126 | ``` 127 | $ cd samples 128 | $ domcompare -s ./expected.xml ./actual.xml 129 | Documents are not equal 130 | /document/element 131 | Attribute 'attribute': expected value '10' instead of '100' 132 | Attribute 'attributeX' is missed 133 | Extra element 'inner2' 134 | /document/element/inner 135 | Element 'node' is missed 136 | /document 137 | Expected CDATA value ' cdata node' instead of 'cdata node ' 138 | ``` 139 | 140 | 141 | DOM Canonic Form 142 | ---------------- 143 | 144 | Implemented as [XMLSerializer](https://developer.mozilla.org/en-US/docs/XMLSerializer) interface 145 | 146 | 147 | Simple rules 148 | 1. Every node (text, node, attribute) on a new line 149 | 2. Empty tags - in a short form 150 | 3. Node indent - 4 spaces, attribute indent - 2 spaces 151 | 4. Attributes are sorted alphabetically 152 | 5. Attribute values are serialized in double quotes 153 | 154 | Consider the following XML-document... 155 | ```xml 156 | 157 | DOM Compare 158 | 159 | 160 | Text node 161 | 162 | 163 | ``` 164 | ...and code snippet... 165 | ```javascript 166 | var canonizingSerializer = new (require('dom-compare').XMLSerializer)(); 167 | var doc = ...; // parse above document somehow 168 | console.log(canonizingSerializer.serializeToString(doc)); 169 | ``` 170 | You'll receive the following output 171 | ```xml 172 | 173 | 174 | DOM Compare 175 | 176 | 177 | 180 | 181 | Text node 182 | 183 | 184 | 185 | ``` 186 | -------------------------------------------------------------------------------- /lib/compare.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | "use strict"; 4 | 5 | var type = require('./node_types'); 6 | var Collector = require('./collector'); 7 | var collapseSpaces = require('./collapse_spaces'); 8 | var normalizeNewlines = require('./normalize_newlines'); 9 | 10 | function Comparator(options, collector) { 11 | this._options = options || {}; 12 | if(!collector) 13 | throw new Error("Collector instance must be specified"); 14 | this._collector = collector; 15 | } 16 | 17 | Comparator.prototype._filterNodes = function(list) { 18 | var ret = [], 19 | i, l, item; 20 | for (i = 0, l = list.length; i < l; i++) { 21 | item = list.item(i); 22 | if (item.nodeType == type.COMMENT_NODE && !this._options.compareComments) 23 | continue; 24 | if (item.nodeType == type.TEXT_NODE && ("" + item.nodeValue).trim() == "") 25 | continue; 26 | ret.push(item); 27 | } 28 | return ret; 29 | }; 30 | 31 | Comparator.prototype._compareNodeList = function(left, right) { 32 | var lLeft = this._filterNodes(left), 33 | lRight = this._filterNodes(right), 34 | i, l, result = true; 35 | 36 | for (i = 0, l = Math.max(lLeft.length, lRight.length); i < l; i++) { 37 | if(lLeft[i] && lRight[i]) { 38 | if (!this.compareNode(lLeft[i], lRight[i])) { 39 | result = false; 40 | } 41 | } 42 | else { 43 | this._collector.collectFailure(lLeft[i], lRight[i]); 44 | result = false; 45 | } 46 | } 47 | return result; 48 | }; 49 | 50 | Comparator.prototype._compareAttributes = function(expected, actual) { 51 | var aExpected = {}, aActual = {}, 52 | i, l; 53 | 54 | if (!expected && !actual) 55 | return true; 56 | 57 | 58 | 59 | for(i = 0, l = expected.length; i < l; i++) { 60 | aExpected[expected[i].nodeName] = expected[i]; 61 | } 62 | 63 | for(i = 0, l = actual.length; i < l; i++) { 64 | aActual[actual[i].nodeName] = actual[i]; 65 | } 66 | 67 | for(i in aExpected) { 68 | // both nodes has an attribute 69 | if(aExpected.hasOwnProperty(i) && aActual.hasOwnProperty(i)) { 70 | // but values is differ 71 | var vExpected = aExpected[i].nodeValue; 72 | var vActual = aActual[i].nodeValue; 73 | if(this._options.stripSpaces && aExpected[i].nodeType != type.CDATA_SECTION_NODE) { 74 | vExpected = vExpected.trim(); 75 | vActual = vActual.trim(); 76 | } 77 | if(this._options.collapseSpaces && aExpected[i].nodeType != type.CDATA_SECTION_NODE) { 78 | vExpected = collapseSpaces(vExpected); 79 | vActual = collapseSpaces(vActual); 80 | } 81 | if(this._options.normalizeNewlines) { 82 | vExpected = normalizeNewlines(vExpected); 83 | vActual = normalizeNewlines(vActual); 84 | } 85 | if(vExpected !== vActual) { 86 | if(!this._collector.collectFailure(aExpected[i], aActual[i])) 87 | return false; 88 | } 89 | // remove to check for extra/missed attributes; 90 | delete aActual[i]; 91 | delete aExpected[i]; 92 | } 93 | } 94 | 95 | // report all missed attributes 96 | for(i in aExpected) { 97 | if(aExpected.hasOwnProperty(i)) 98 | if(!this._collector.collectFailure(aExpected[i], null)) 99 | return false; 100 | } 101 | 102 | // report all extra attributes 103 | for(i in aActual) { 104 | if(aActual.hasOwnProperty(i)) 105 | if(!this._collector.collectFailure(null, aActual[i])) 106 | return false; 107 | } 108 | 109 | return true; 110 | }; 111 | 112 | Comparator.prototype.compareNode = function(left, right) { 113 | var vLeft, vRight, r; 114 | 115 | if (typeof left === 'string' || typeof right === 'string') { 116 | throw new Error('String comparison is not supported. You must parse string to document to perform comparison.'); 117 | } 118 | 119 | if (left.nodeName === right.nodeName && left.nodeType == right.nodeType) { 120 | switch (left.nodeType) { 121 | case type.DOCUMENT_NODE: 122 | return this.compareNode(left.documentElement, right.documentElement); 123 | case type.ELEMENT_NODE: 124 | return this._compareAttributes(left.attributes, right.attributes) && 125 | this._compareNodeList(left.childNodes, right.childNodes); 126 | case type.TEXT_NODE: 127 | // fallthrough 128 | case type.CDATA_SECTION_NODE: 129 | // fallthrough 130 | case type.DOCUMENT_FRAGMENT_NODE: 131 | // fallthrough 132 | case type.COMMENT_NODE: 133 | if (left.nodeType == type.COMMENT_NODE && !this._options.compareComments) 134 | return true; 135 | vLeft = "" + left.nodeValue; 136 | vRight = "" + right.nodeValue; 137 | if (this._options.stripSpaces && left.nodeType !== type.CDATA_SECTION_NODE) { 138 | vLeft = vLeft.trim(); 139 | vRight = vRight.trim(); 140 | } 141 | if (this._options.collapseSpaces && left.nodeType !== type.CDATA_SECTION_NODE) { 142 | vLeft = collapseSpaces(vLeft); 143 | vRight = collapseSpaces(vRight); 144 | } 145 | if (this._options.normalizeNewlines) { 146 | vLeft = normalizeNewlines(vLeft); 147 | vRight = normalizeNewlines(vRight); 148 | } 149 | r = vLeft === vRight; 150 | return !r ? this._collector.collectFailure(left, right) : r; 151 | default: 152 | throw Error("Node type " + left.nodeType + " comparison is not implemented"); 153 | } 154 | } else 155 | return this._collector.collectFailure(left, right); 156 | }; 157 | 158 | module.exports = function(a, b, options) { 159 | 160 | 161 | var collector = new Collector(options); 162 | var comparator = new Comparator(options, collector); 163 | comparator.compareNode(a, b); 164 | 165 | return collector; 166 | 167 | }; 168 | 169 | })(); 170 | -------------------------------------------------------------------------------- /lib/collector.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | "use strict"; 4 | 5 | var type = require('./node_types'); 6 | var revxpath = require('./revxpath.js'); 7 | var collapseSpaces = require('./collapse_spaces'); 8 | var normalizeNewlines = require('./normalize_newlines'); 9 | 10 | var typeMap = {}, 11 | comparatorTypeMap = {}; 12 | 13 | typeMap[type.ATTRIBUTE_NODE] = "attribute"; 14 | typeMap[type.ELEMENT_NODE] = "element"; 15 | typeMap[type.TEXT_NODE] = "text node"; 16 | typeMap[type.COMMENT_NODE] = "comment node"; 17 | typeMap[type.CDATA_SECTION_NODE] = "CDATA node"; 18 | typeMap[type.DOCUMENT_NODE] = "document"; 19 | typeMap[type.DOCUMENT_FRAGMENT_NODE] = "document fragment"; 20 | 21 | Object.keys(type).forEach(function(k){ 22 | comparatorTypeMap[type[k]] = k; 23 | }); 24 | 25 | function Collector(options) { 26 | this._diff = []; 27 | this._options = options || {}; 28 | } 29 | 30 | Collector.prototype._describeNode = function(node) { 31 | if(node.nodeType == type.TEXT_NODE || 32 | node.nodeType == type.CDATA_SECTION_NODE || 33 | node.nodeType == type.COMMENT_NODE) { 34 | var nodeValue = node.nodeValue; 35 | 36 | if(this._options.stripSpaces) { 37 | nodeValue = nodeValue.trim(); 38 | } 39 | if(this._options.collapseSpaces) { 40 | nodeValue = collapseSpaces(nodeValue); 41 | } 42 | if(this._options.normalizeNewlines) { 43 | nodeValue = normalizeNewlines(nodeValue); 44 | } 45 | 46 | return "'" + nodeValue + "'"; 47 | } 48 | else 49 | return "'" + node.nodeName + "'"; 50 | }; 51 | 52 | Collector.prototype.getDifferences = function() { 53 | return this._diff; 54 | }; 55 | 56 | Collector.prototype.getResult = function() { 57 | return this._diff.length == 0 58 | }; 59 | 60 | Collector.prototype.collectFailure = function(expected, actual) { 61 | 62 | var msg, canContinue = true, vExpected, vActual, ref = expected || actual, cmprtr, r; 63 | 64 | if(this._options.comparators && (cmprtr = this._options.comparators[comparatorTypeMap[ref.nodeType]])) { 65 | if(!(cmprtr instanceof Array)) 66 | cmprtr = [ cmprtr ]; 67 | for(var i = 0, l = cmprtr.length; i < l; i++) { 68 | r = cmprtr[i](expected, actual); 69 | if(r) { 70 | // true -> ignore differences. Stop immediately, continue; 71 | if(r === true) { 72 | return true; 73 | } 74 | // string - treat as error message, continue; 75 | else if(typeof r == 'string') { 76 | msg = r; 77 | canContinue = true; 78 | } 79 | // object - .message = error message, .stop - stop flag 80 | else if(typeof r == 'object') { 81 | msg = r.message; 82 | canContinue = !(!!r.stop); 83 | } 84 | break; 85 | } 86 | 87 | } 88 | } 89 | 90 | if(!msg) { 91 | 92 | if(expected && !actual) { 93 | msg = typeMap[expected.nodeType].charAt(0).toUpperCase() + typeMap[expected.nodeType].substr(1) + 94 | " " + this._describeNode(expected) + " is missed"; 95 | canContinue = true; 96 | } 97 | else if(!expected && actual) { 98 | msg = "Extra " + typeMap[actual.nodeType] + " " + this._describeNode(actual); 99 | canContinue = true; 100 | } 101 | else { 102 | if(expected.nodeType == actual.nodeType) { 103 | if(expected.nodeName == actual.nodeName) { 104 | vExpected = expected.nodeValue; 105 | vActual = actual.nodeValue; 106 | if(this._options.stripSpaces && expected.nodeType != type.CDATA_SECTION_NODE) { 107 | vExpected = vExpected.trim(); 108 | vActual = vActual.trim(); 109 | } 110 | if(this._options.collapseSpaces && expected.nodeType != type.CDATA_SECTION_NODE) { 111 | vExpected = collapseSpaces(vExpected); 112 | vActual = collapseSpaces(vActual); 113 | } 114 | if(this._options.normalizeNewlines) { 115 | vExpected = normalizeNewlines(vExpected); 116 | vActual = normalizeNewlines(vActual); 117 | } 118 | if(vExpected == vActual) 119 | throw new Error("Nodes are considered equal but shouldn't"); 120 | else { 121 | switch(expected.nodeType) { 122 | case type.ATTRIBUTE_NODE: 123 | msg = "Attribute '" + expected.nodeName + "': expected value '" + vExpected + "' instead of '" + vActual + "'"; 124 | break; 125 | case type.COMMENT_NODE: 126 | msg = "Expected comment value '" + vExpected + "' instead of '" + vActual + "'"; 127 | break; 128 | case type.CDATA_SECTION_NODE: 129 | msg = "Expected CDATA value '" + vExpected + "' instead of '" + vActual + "'"; 130 | break; 131 | case type.TEXT_NODE: 132 | msg = "Expected text '" + vExpected + "' instead of '" + vActual + "'"; 133 | break; 134 | default: 135 | throw new Error("nodeValue is not equal, but nodeType is unexpected"); 136 | } 137 | canContinue = true; 138 | } 139 | } 140 | else { 141 | msg = "Expected " + typeMap[expected.nodeType] + 142 | " '" + expected.nodeName + "' instead of '" + actual.nodeName + "'"; 143 | canContinue = false; 144 | } 145 | } 146 | else { 147 | msg = "Expected node of type " + expected.nodeType + 148 | " (" + typeMap[expected.nodeType] + ") instead of " + 149 | actual.nodeType + " (" + typeMap[actual.nodeType] + ")"; 150 | canContinue = false; 151 | } 152 | } 153 | } 154 | 155 | this._diff.push({ 156 | node: revxpath(ref.ownerElement || ref.parentNode), 157 | message: msg 158 | }); 159 | 160 | return canContinue; 161 | }; 162 | 163 | module.exports = Collector; 164 | 165 | 166 | })(); 167 | -------------------------------------------------------------------------------- /test/test-collector.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var xmldom = require("xmldom"); 3 | var parser = new xmldom.DOMParser(); 4 | var domCompare = require("../"); 5 | var compare = domCompare.compare; 6 | 7 | describe("Error collection", function(){ 8 | 9 | it("In case root node differs - no any other checks are made", function(){ 10 | 11 | var doc1 = parser.parseFromString(""); 12 | var doc2 = parser.parseFromString(""); 13 | 14 | var result = compare(doc1, doc2, {}); 15 | 16 | var failures = result.getDifferences(); 17 | 18 | assert.equal(1, failures.length); 19 | assert.equal("Expected element 'root1' instead of 'root2'", failures[0].message); 20 | 21 | }); 22 | 23 | describe("Attributes", function(){ 24 | 25 | it("All attributes at single node are compared", function(){ 26 | 27 | var doc1 = parser.parseFromString(""); 28 | var doc2 = parser.parseFromString(""); 29 | 30 | var result = compare(doc1, doc2, {}); 31 | 32 | var failures = result.getDifferences(); 33 | 34 | assert.equal(3, failures.length); 35 | assert.equal("Attribute 'attr1': expected value '1' instead of '10'", failures[0].message); 36 | assert.equal("Attribute 'attr3' is missed", failures[1].message); 37 | assert.equal("Extra attribute 'attr4'", failures[2].message); 38 | 39 | }); 40 | 41 | it("All attributes that are not found is reported", function(){ 42 | 43 | var doc1 = parser.parseFromString(""); 44 | var doc2 = parser.parseFromString(""); 45 | 46 | var result = compare(doc1, doc2, {}); 47 | 48 | var failures = result.getDifferences(); 49 | 50 | assert.equal(2, failures.length); 51 | assert.equal("Attribute 'attr2' is missed", failures[0].message); 52 | assert.equal("Attribute 'attr3' is missed", failures[1].message); 53 | 54 | // Case: Target has no attributes at all 55 | doc1 = parser.parseFromString(""); 56 | doc2 = parser.parseFromString(""); 57 | 58 | result = compare(doc1, doc2, {}); 59 | 60 | failures = result.getDifferences(); 61 | 62 | assert.equal(2, failures.length); 63 | assert.equal("Attribute 'attr2' is missed", failures[0].message); 64 | assert.equal("Attribute 'attr3' is missed", failures[1].message); 65 | 66 | }); 67 | 68 | it("All extra attributes is reported", function(){ 69 | 70 | var doc1 = parser.parseFromString(""); 71 | var doc2 = parser.parseFromString(""); 72 | 73 | var result = compare(doc1, doc2, {}); 74 | 75 | var failures = result.getDifferences(); 76 | 77 | assert.equal(2, failures.length); 78 | assert.equal("Extra attribute 'attr2'", failures[0].message); 79 | assert.equal("Extra attribute 'attr3'", failures[1].message); 80 | 81 | // Case: Source has no attributes at all 82 | doc1 = parser.parseFromString(""); 83 | doc2 = parser.parseFromString(""); 84 | 85 | result = compare(doc1, doc2, {}); 86 | 87 | failures = result.getDifferences(); 88 | 89 | assert.equal(2, failures.length); 90 | assert.equal("Extra attribute 'attr2'", failures[0].message); 91 | assert.equal("Extra attribute 'attr3'", failures[1].message); 92 | 93 | }); 94 | 95 | }); 96 | 97 | describe("Nodes comparison", function(){ 98 | 99 | it("Differences reported by types first", function(){ 100 | var doc1 = parser.parseFromString(""); 101 | var doc2 = parser.parseFromString("TextTextText"); 102 | 103 | var result = compare(doc1, doc2, {}); 104 | 105 | var failures = result.getDifferences(); 106 | 107 | assert.equal(1, failures.length); 108 | assert.equal("Expected node of type 1 (element) instead of 3 (text node)", failures[0].message); 109 | }); 110 | 111 | it("... and by node names then", function(){ 112 | var doc1 = parser.parseFromString(""); 113 | var doc2 = parser.parseFromString(""); 114 | 115 | var result = compare(doc1, doc2, {}); 116 | 117 | var failures = result.getDifferences(); 118 | 119 | assert.equal(1, failures.length); 120 | assert.equal("Expected element 'a' instead of 'b'", failures[0].message); 121 | }); 122 | 123 | describe("Elements", function(){ 124 | 125 | }); 126 | 127 | describe("Text nodes", function(){ 128 | 129 | it("Extra nodes reported", function(){ 130 | var doc1 = parser.parseFromString("First"); 131 | var doc2 = parser.parseFromString("FirstSecond"); 132 | 133 | var result = compare(doc1, doc2, {}); 134 | 135 | var failures = result.getDifferences(); 136 | 137 | assert.equal(1, failures.length); 138 | assert.equal("Extra text node 'Second'", failures[0].message); 139 | }); 140 | 141 | it("Not found nodes reported", function(){ 142 | var doc1 = parser.parseFromString("FirstSecond"); 143 | var doc2 = parser.parseFromString("First"); 144 | 145 | var result = compare(doc1, doc2, {}); 146 | 147 | var failures = result.getDifferences(); 148 | 149 | assert.equal(1, failures.length); 150 | assert.equal("Text node 'Second' is missed", failures[0].message); 151 | 152 | doc1 = parser.parseFromString("First Second"); 153 | doc2 = parser.parseFromString(" First"); 154 | 155 | result = compare(doc1, doc2, { stripSpaces: true }); 156 | 157 | failures = result.getDifferences(); 158 | 159 | assert.equal(1, failures.length); 160 | assert.equal("Text node 'Second' is missed", failures[0].message); 161 | }); 162 | 163 | it("Different content reported", function(){ 164 | var doc1 = parser.parseFromString("Foo"); 165 | var doc2 = parser.parseFromString("Bar"); 166 | 167 | var result = compare(doc1, doc2, {}); 168 | 169 | var failures = result.getDifferences(); 170 | 171 | assert.equal(1, failures.length); 172 | assert.equal("Expected text 'Foo' instead of 'Bar'", failures[0].message); 173 | }) 174 | 175 | }) 176 | 177 | }); 178 | 179 | 180 | describe("Custom comparison routine", function(){ 181 | 182 | it("User can provide custom comparison routine, it can be used to extended reporting", function(){ 183 | 184 | var doc1 = parser.parseFromString(""); 185 | var doc2 = parser.parseFromString(""); 186 | 187 | var result = compare(doc1, doc2, { 188 | comparators: { 189 | ATTRIBUTE_NODE: function(e, a) { 190 | if(e.nodeValue > a.nodeValue) 191 | return "Actual value is less than expected"; 192 | else if(e.nodeValue < a.nodeValue) 193 | return "Actual value is greater than expected"; 194 | } 195 | } 196 | }); 197 | 198 | var failures = result.getDifferences(); 199 | 200 | assert.equal(1, failures.length); 201 | assert.equal("Actual value is greater than expected", failures[0].message); 202 | 203 | 204 | }); 205 | 206 | it("Custom comparison routine can ignore node differences", function(){ 207 | 208 | var doc1 = parser.parseFromString(""); 209 | var doc2 = parser.parseFromString(""); 210 | 211 | var result = compare(doc1, doc2, { 212 | comparators: { 213 | ATTRIBUTE_NODE: function() { 214 | return true; 215 | } 216 | } 217 | }); 218 | 219 | var failures = result.getDifferences(); 220 | 221 | assert.equal(0, failures.length); 222 | 223 | }); 224 | 225 | it("Custom comparison routine can skip node checking. It will be processed by common routine.", function(){ 226 | 227 | var doc1 = parser.parseFromString(""); 228 | var doc2 = parser.parseFromString(""); 229 | 230 | var result = compare(doc1, doc2, { 231 | comparators: { 232 | ATTRIBUTE_NODE: function(e, a) { 233 | if(e.nodeName == 'attr') { 234 | if(e.nodeValue > a.nodeValue) 235 | return "Actual value is less than expected"; 236 | else if(e.nodeValue < a.nodeValue) 237 | return "Actual value is greater than expected"; 238 | } 239 | } 240 | } 241 | }); 242 | 243 | var failures = result.getDifferences(); 244 | 245 | assert.equal(2, failures.length); 246 | assert.equal("Actual value is greater than expected", failures[0].message); 247 | assert.equal("Attribute 'm': expected value '2' instead of '3'", failures[1].message); 248 | 249 | 250 | }); 251 | 252 | }); 253 | 254 | 255 | }); -------------------------------------------------------------------------------- /test/test-compare.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var xmldom = require("xmldom"); 3 | var parser = new xmldom.DOMParser(); 4 | var domCompare = require("../"); 5 | var compare = domCompare.compare; 6 | 7 | describe('Compare', function () { 8 | 9 | it('equal documents reports no differences', function() { 10 | assert.equal(true, compare( 11 | parser.parseFromString('
'), 12 | parser.parseFromString('
') 13 | ).getResult()); 14 | }); 15 | 16 | describe('Strings', function() { 17 | it('string comparison is not supported, error is thrown', function() { 18 | var errorText; 19 | try { 20 | compare('', ''); 21 | } catch(e) { 22 | errorText = '' + e; 23 | } 24 | assert.equal( 25 | 'Error: String comparison is not supported. You must parse string to document to perform comparison.', 26 | errorText); 27 | }); 28 | }); 29 | 30 | 31 | describe('Documents', function () { 32 | it('with different root node names are different', function () { 33 | var doc1 = parser.parseFromString(""); 34 | var doc2 = parser.parseFromString("
"); 35 | assert.equal(false, compare(doc1, doc2).getResult()); 36 | }); 37 | 38 | it('with same root node names are same', function () { 39 | var doc1 = parser.parseFromString(""); 40 | var doc2 = parser.parseFromString(""); 41 | assert.equal(true, compare(doc1, doc2).getResult()); 42 | }); 43 | 44 | it('with same attribute set but different order are same', function () { 45 | var doc1 = parser.parseFromString(""); 46 | var doc2 = parser.parseFromString(""); 47 | assert.equal(true, compare(doc1, doc2).getResult()); 48 | }); 49 | 50 | it('with different attributes set is different', function () { 51 | var doc1 = parser.parseFromString(""); 52 | var doc2 = parser.parseFromString(""); 53 | assert.equal(false, compare(doc1, doc2).getResult()); 54 | }); 55 | 56 | it('with same attributes but with different values is different', function () { 57 | var doc1 = parser.parseFromString(""); 58 | var doc2 = parser.parseFromString(""); 59 | assert.equal(false, compare(doc1, doc2).getResult()); 60 | }); 61 | }); 62 | 63 | describe('Document fragments', function () { 64 | it('supported', function () { 65 | var doc = parser.parseFromString(""); 66 | 67 | var frag1 = doc.createDocumentFragment(); 68 | var frag2 = doc.createDocumentFragment(); 69 | 70 | frag1.appendChild(doc.createElement('div')); 71 | frag2.appendChild(doc.createElement('div')); 72 | 73 | assert.equal(true, compare(frag1, frag2).getResult()); 74 | }); 75 | }); 76 | 77 | describe('Nodes', function () { 78 | it('with different names are different', function () { 79 | var doc1 = parser.parseFromString(""); 80 | var doc2 = parser.parseFromString(""); 81 | assert.equal(false, compare(doc1, doc2).getResult()); 82 | }); 83 | 84 | it('with same names are same', function () { 85 | var doc1 = parser.parseFromString(""); 86 | var doc2 = parser.parseFromString(""); 87 | assert.equal(true, compare(doc1, doc2).getResult()); 88 | }); 89 | 90 | it('with same attribute set but different order are same', function () { 91 | var doc1 = parser.parseFromString(""); 92 | var doc2 = parser.parseFromString(""); 93 | assert.equal(true, compare(doc1, doc2).getResult()); 94 | }); 95 | 96 | it('with different attributes set is different', function () { 97 | var doc1 = parser.parseFromString(""); 98 | var doc2 = parser.parseFromString(""); 99 | assert.equal(false, compare(doc1, doc2).getResult()); 100 | }); 101 | 102 | it('with same attributes but with different values is different', function () { 103 | var doc1 = parser.parseFromString(""); 104 | var doc2 = parser.parseFromString(""); 105 | assert.equal(false, compare(doc1, doc2).getResult()); 106 | 107 | describe("spaces matters", function(){ 108 | 109 | it("", function(){ 110 | var doc1 = parser.parseFromString(""); 111 | var doc2 = parser.parseFromString(""); 112 | assert.equal(false, compare(doc1, doc2).getResult()); 113 | }); 114 | 115 | it("but can be omitted", function(){ 116 | doc1 = parser.parseFromString(""); 117 | doc2 = parser.parseFromString(""); 118 | assert.equal(true, compare(doc1, doc2, { stripSpaces: true }).getResult()); 119 | }); 120 | 121 | it("but can be collapsed", function(){ 122 | doc1 = parser.parseFromString(""); 123 | doc2 = parser.parseFromString(""); 124 | assert.equal(true, compare(doc1, doc2, { collapseSpaces: true }).getResult()); 125 | }); 126 | 127 | it("but newlines can be normalized", function(){ 128 | doc1 = parser.parseFromString(""); 129 | doc2 = parser.parseFromString(""); 130 | assert.equal(true, compare(doc1, doc2, { normalizeNewlines: true }).getResult()); 131 | }); 132 | }); 133 | 134 | }); 135 | }); 136 | 137 | describe('Node Lists', function () { 138 | it("order of child nodes matters", function () { 139 | var doc1 = parser.parseFromString(""); 140 | var doc2 = parser.parseFromString(""); 141 | assert.equal(false, compare(doc1, doc2).getResult()); 142 | }); 143 | 144 | it("full list of child nodes is compared", function() { 145 | var doc1 = parser.parseFromString(""); 146 | var doc2 = parser.parseFromString(""); 147 | assert.equal(false, compare(doc1, doc2).getResult()); 148 | assert.equal(2, compare(doc1, doc2).getDifferences().length); 149 | }); 150 | }); 151 | 152 | describe("Text nodes", function () { 153 | 154 | describe('Comments', function () { 155 | it("normally comments are ignored", function () { 156 | var doc1 = parser.parseFromString(""); 157 | var doc2 = parser.parseFromString(""); 158 | assert.equal(true, compare(doc1, doc2).getResult()); 159 | 160 | doc1 = parser.parseFromString(""); 161 | doc2 = parser.parseFromString(""); 162 | assert.equal(true, compare(doc1, doc2).getResult()); 163 | }); 164 | 165 | it("when `compareComments` options is set - comments are compared too", function () { 166 | var doc1 = parser.parseFromString(""); 167 | var doc2 = parser.parseFromString(""); 168 | assert.equal(false, compare(doc1, doc2, { compareComments: true }).getResult()); 169 | 170 | doc1 = parser.parseFromString(""); 171 | doc2 = parser.parseFromString(""); 172 | assert.equal(true, compare(doc1, doc2, { compareComments: true }).getResult()); 173 | 174 | doc1 = parser.parseFromString(""); 175 | doc2 = parser.parseFromString(""); 176 | assert.equal(false, compare(doc1, doc2, { compareComments: true }).getResult()); 177 | }); 178 | 179 | describe("Extra whitespace stripping", function () { 180 | it("normally all whitespaces at the beginning/end are preserved", function () { 181 | var doc1 = parser.parseFromString(""); 182 | var doc2 = parser.parseFromString(""); 183 | assert.equal(true, compare(doc1, doc2, { compareComments: true }).getResult()); 184 | 185 | doc1 = parser.parseFromString(""); 186 | doc2 = parser.parseFromString(""); 187 | assert.equal(false, compare(doc1, doc2, { compareComments: true }).getResult()); 188 | 189 | doc1 = parser.parseFromString(""); 190 | doc2 = parser.parseFromString(""); 191 | assert.equal(false, compare(doc1, doc2, { compareComments: true }).getResult()); 192 | }); 193 | 194 | it("`stripSpaces` option strips them", function () { 195 | var doc1 = parser.parseFromString(""); 196 | var doc2 = parser.parseFromString(""); 197 | assert.equal(true, compare(doc1, doc2, { 198 | compareComments: true, 199 | stripSpaces: true 200 | }).getResult()); 201 | 202 | doc1 = parser.parseFromString(""); 203 | doc2 = parser.parseFromString(""); 204 | assert.equal(true, compare(doc1, doc2, { 205 | compareComments: true, 206 | stripSpaces: true 207 | }).getResult()); 208 | 209 | doc1 = parser.parseFromString(""); 210 | doc2 = parser.parseFromString(""); 211 | assert.equal(true, compare(doc1, doc2, { 212 | compareComments: true, 213 | stripSpaces: true 214 | }).getResult()); 215 | }); 216 | }); 217 | 218 | describe("Whitespace collapsing", function () { 219 | it("normally all whitespaces are preserved", function () { 220 | var doc1 = parser.parseFromString(""); 221 | var doc2 = parser.parseFromString(""); 222 | assert.equal(true, compare(doc1, doc2, { compareComments: true }).getResult()); 223 | 224 | doc1 = parser.parseFromString(""); 225 | doc2 = parser.parseFromString(""); 226 | assert.equal(false, compare(doc1, doc2, { compareComments: true }).getResult()); 227 | }); 228 | 229 | it("`collapseSpaces` option strips them", function () { 230 | var doc1 = parser.parseFromString(""); 231 | var doc2 = parser.parseFromString(""); 232 | assert.equal(true, compare(doc1, doc2, { 233 | compareComments: true, 234 | collapseSpaces: true 235 | }).getResult()); 236 | 237 | doc1 = parser.parseFromString(""); 238 | doc2 = parser.parseFromString(""); 239 | assert.equal(true, compare(doc1, doc2, { 240 | compareComments: true, 241 | collapseSpaces: true 242 | }).getResult()); 243 | 244 | doc1 = parser.parseFromString(""); 245 | doc2 = parser.parseFromString(""); 246 | assert.equal(true, compare(doc1, doc2, { 247 | compareComments: true, 248 | collapseSpaces: true 249 | }).getResult()); 250 | 251 | doc1 = parser.parseFromString(""); 252 | doc2 = parser.parseFromString(""); 253 | assert.equal(true, compare(doc1, doc2, { 254 | compareComments: true, 255 | collapseSpaces: true 256 | }).getResult()); 257 | }); 258 | }); 259 | 260 | describe("Newline normalizing", function () { 261 | it("normally all whitespaces at the beginning/end are preserved", function () { 262 | var doc1 = parser.parseFromString(""); 263 | var doc2 = parser.parseFromString(""); 264 | assert.equal(true, compare(doc1, doc2, { compareComments: true }).getResult()); 265 | 266 | doc1 = parser.parseFromString(""); 267 | doc2 = parser.parseFromString(""); 268 | assert.equal(false, compare(doc1, doc2, { compareComments: true }).getResult()); 269 | }); 270 | 271 | it("`normalizeNewlines` option normalizes them", function () { 272 | var doc1 = parser.parseFromString(""); 273 | var doc2 = parser.parseFromString(""); 274 | assert.equal(true, compare(doc1, doc2, { 275 | compareComments: true, 276 | normalizeNewlines: true 277 | }).getResult()); 278 | }); 279 | }); 280 | }); 281 | 282 | describe("Text", function () { 283 | it("compared by default with all whitespaces", function () { 284 | var doc1 = parser.parseFromString("AB"); 285 | var doc2 = parser.parseFromString("AB"); 286 | assert.equal(true, compare(doc1, doc2).getResult()); 287 | 288 | doc1 = parser.parseFromString("AB"); 289 | doc2 = parser.parseFromString("A B"); 290 | assert.equal(false, compare(doc1, doc2).getResult()); 291 | 292 | doc1 = parser.parseFromString("BA"); 293 | doc2 = parser.parseFromString("AB"); 294 | assert.equal(false, compare(doc1, doc2).getResult()); 295 | 296 | doc1 = parser.parseFromString("A"); 297 | doc2 = parser.parseFromString(" A "); 298 | assert.equal(false, compare(doc1, doc2).getResult()); 299 | }); 300 | 301 | it("empty text nodes are always ignored", function(){ 302 | 303 | var doc1 = parser.parseFromString(" "); 304 | var doc2 = parser.parseFromString(" "); 305 | assert.equal(true, compare(doc1, doc2).getResult()); 306 | 307 | }); 308 | 309 | it("set `stripSpaces` option to get rid of them", function () { 310 | var doc1 = parser.parseFromString("A"); 311 | var doc2 = parser.parseFromString(" A "); 312 | assert.equal(true, compare(doc1, doc2, { stripSpaces: true }).getResult()); 313 | }); 314 | 315 | it("set `collapseSpaces` option to collapse them", function () { 316 | var doc1 = parser.parseFromString(" A \n\r\t B "); 317 | var doc2 = parser.parseFromString(" A \t\n\r B "); 318 | assert.equal(true, compare(doc1, doc2, { collapseSpaces: true }).getResult()); 319 | 320 | doc1 = parser.parseFromString(" \n\r\t A B "); 321 | doc2 = parser.parseFromString(" \t\n\r A B "); 322 | assert.equal(true, compare(doc1, doc2, { collapseSpaces: true }).getResult()); 323 | 324 | doc1 = parser.parseFromString(" A B \n\r\t "); 325 | doc2 = parser.parseFromString(" A B \t\n\r "); 326 | assert.equal(true, compare(doc1, doc2, { collapseSpaces: true }).getResult()); 327 | 328 | doc1 = parser.parseFromString(" \n\r\t A B \n\r\t "); 329 | doc2 = parser.parseFromString(" A \t\n\r B \t\n\r "); 330 | assert.equal(true, compare(doc1, doc2, { collapseSpaces: true }).getResult()); 331 | }); 332 | 333 | it("set `normalizeNewlines` option to normalize newlines", function () { 334 | var doc1 = parser.parseFromString(" \r\n A \r B \n "); 335 | var doc2 = parser.parseFromString(" \n A \r\n B \r "); 336 | assert.equal(true, compare(doc1, doc2, { normalizeNewlines: true }).getResult()); 337 | }); 338 | }); 339 | 340 | describe("CDATA", function () { 341 | it("compared as text nodes but `stripSpaces` and `collapseSpaces` are not respected", function () { 342 | var doc1 = parser.parseFromString(""); 343 | var doc2 = parser.parseFromString(""); 344 | assert.equal(true, compare(doc1, doc2).getResult()); 345 | 346 | doc1 = parser.parseFromString(""); 347 | doc2 = parser.parseFromString(""); 348 | assert.equal(false, compare(doc1, doc2).getResult()); 349 | 350 | doc1 = parser.parseFromString(""); 351 | doc2 = parser.parseFromString(""); 352 | assert.equal(false, compare(doc1, doc2).getResult()); 353 | 354 | doc1 = parser.parseFromString(""); 355 | doc2 = parser.parseFromString(""); 356 | assert.equal(false, compare(doc1, doc2, { stripSpaces: true }).getResult()); 357 | 358 | doc1 = parser.parseFromString(""); 359 | doc2 = parser.parseFromString(""); 360 | assert.equal(false, compare(doc1, doc2, { collapseSpaces: true }).getResult()); 361 | 362 | doc1 = parser.parseFromString(""); 363 | doc2 = parser.parseFromString(""); 364 | assert.equal(false, compare(doc1, doc2, { collapseSpaces: true }).getResult()); 365 | 366 | doc1 = parser.parseFromString(""); 367 | doc2 = parser.parseFromString(""); 368 | assert.equal(false, compare(doc1, doc2, { collapseSpaces: true }).getResult()); 369 | 370 | doc1 = parser.parseFromString(""); 371 | doc2 = parser.parseFromString(""); 372 | assert.equal(false, compare(doc1, doc2, { collapseSpaces: true }).getResult()); 373 | }); 374 | 375 | it("set `normalizeNewlines` option to normalize newlines", function () { 376 | var doc1 = parser.parseFromString(""); 377 | var doc2 = parser.parseFromString(""); 378 | assert.equal(true, compare(doc1, doc2, { normalizeNewlines: true }).getResult()); 379 | }); 380 | }); 381 | }); 382 | 383 | describe('Regression tests', function() { 384 | it('Issue #36', function() { 385 | var str1 = ` 386 | Tove 387 | Jani 388 | Reminder 389 | I got changed 390 | `; 391 | var str2 = ` 392 | Tove 393 | Jani 394 | Reminder 395 | Don't forget me this weekend! 396 | seventeen 397 |
street of freedom
398 | 399 | Hiyo 400 | 401 |
`; 402 | 403 | var doc1 = parser.parseFromString(str1); 404 | var doc2 = parser.parseFromString(str2); 405 | assert.equal(false, compare(doc1, doc2).getResult()); 406 | var diff = compare(doc1, doc2).getDifferences(); 407 | assert.equal(4, diff.length); 408 | assert.equal( 409 | "Expected text 'I got changed' instead of 'Don't forget me this weekend!'", 410 | diff[0].message); 411 | assert.equal( 412 | "Extra element 'age'", 413 | diff[1].message); 414 | assert.equal( 415 | "Extra element 'address'", 416 | diff[2].message); 417 | assert.equal( 418 | "Extra element 'anotherelement'", 419 | diff[3].message); 420 | }); 421 | }); 422 | }); 423 | --------------------------------------------------------------------------------