├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── example.js ├── index.js ├── lib └── tokenize.js ├── package.json └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | tmp/ 4 | dist/ 5 | npm-debug.log* 6 | .DS_Store 7 | .nyc_output 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | node_js: 2 | - "4" 3 | - "5" 4 | - "6" 5 | - "7" 6 | sudo: false 7 | language: node_js 8 | script: "npm run test" 9 | # after_success: "npm i -g codecov && npm run coverage && codecov" 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Yoshua Wuyts 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # assert-html [![stability][0]][1] 2 | [![npm version][2]][3] [![build status][4]][5] 3 | [![downloads][8]][9] [![js-standard-style][10]][11] 4 | 5 | Assert two HTML strings are equal. Similar to [spok][spok] but for HTML. Use 6 | [assert-snapshot](https://github.com/yoshuawuyts/assert-snapshot) if you want 7 | an automated workflow. 8 | 9 | ## Usage 10 | ```js 11 | var assertHtml = require('assert-html') 12 | var tape = require('tape') 13 | var html = require('bel') 14 | 15 | tape('compare two DOM strings', function (assert) { 16 | var a, b 17 | a = html`
hello planet
`.toString() 18 | b = html`
hello world
`.toString() 19 | assertHtml(assert, a, b) 20 | 21 | a = html`
hello planet
`.toString() 22 | b = html`
hello planet
`.toString() 23 | assertHtml(assert, a, b) 24 | assert.end() 25 | }) 26 | ``` 27 | 28 | Outputs: 29 | ```tap 30 | TAP version 13 31 | # compare two DOM strings 32 | ok 1
33 | not ok 2 ·· hello world 34 | --- 35 | operator: equal 36 | expected: '·· hello world' 37 | actual: '·· hello planet' 38 | at: assertHtml (/Users/anon/src/shama/assert-html/index.js:59:13) 39 | ... 40 | ok 3
41 | ok 4
42 | ok 5 ·· 43 | ok 6 ·· ·· hello 44 | ok 7 ·· 45 | ok 8 ·· planet 46 | ok 9
47 | 48 | 1..9 49 | # tests 9 50 | # pass 8 51 | # fail 1 52 | 53 | ``` 54 | 55 | ## API 56 | ### `assertHtml(assert, actual, expected)` 57 | Assert two DOM strings are equal using a custom assert function. Calls 58 | `assert.equal()` method from the assert function. 59 | 60 | ## See Also 61 | - [thlorenz/spok](https://github.com/thlorenz/spok) 62 | - [yoshuawuyts/assert-snapshot](https://github.com/yoshuawuyts/assert-snapshot) 63 | 64 | ## License 65 | [MIT](https://tldrlegal.com/license/mit-license) 66 | 67 | [0]: https://img.shields.io/badge/stability-experimental-orange.svg?style=flat-square 68 | [1]: https://nodejs.org/api/documentation.html#documentation_stability_index 69 | [2]: https://img.shields.io/npm/v/assert-html.svg?style=flat-square 70 | [3]: https://npmjs.org/package/assert-html 71 | [4]: https://img.shields.io/travis/yoshuawuyts/assert-html/master.svg?style=flat-square 72 | [5]: https://travis-ci.org/yoshuawuyts/assert-html 73 | [6]: https://img.shields.io/codecov/c/github/yoshuawuyts/assert-html/master.svg?style=flat-square 74 | [7]: https://codecov.io/github/yoshuawuyts/assert-html 75 | [8]: http://img.shields.io/npm/dm/assert-html.svg?style=flat-square 76 | [9]: https://npmjs.org/package/assert-html 77 | [10]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square 78 | [11]: https://github.com/feross/standard 79 | [spok]: https://github.com/thlorenz/spok 80 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | var assertHtml = require('./') 2 | var tape = require('tape') 3 | var html = require('bel') 4 | 5 | tape('compare two DOM strings', function (assert) { 6 | var a, b 7 | a = html`
hello planet
`.toString() 8 | b = html`
hello world
`.toString() 9 | assertHtml(assert, a, b) 10 | 11 | a = html`
hello planet
`.toString() 12 | b = html`
hello planet
`.toString() 13 | assertHtml(assert, a, b) 14 | 15 | a = html`
hello planet
`.toString() 16 | b = html`
hello planet
`.toString() 17 | assertHtml(assert, a, b) 18 | 19 | a = html`
hello planet
`.toString() 20 | b = html`
hello planet
`.toString() 21 | assertHtml(assert, a, b) 22 | 23 | assert.end() 24 | }) 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var shallowEqual = require('shallow-equal/objects') 2 | var assert = require('assert') 3 | 4 | var tokenize = require('./lib/tokenize') 5 | 6 | // https://stackoverflow.com/questions/317053/regular-expression-for-extracting-tag-attributes 7 | var matchAttributes = /(\S+)=["']?((?:.(?!["']?\s+(?:\S+)=|[>"']))+.)["']?/g 8 | var matchOpenTags = /(^' && !matchOpenTags.test(leftString)) { 44 | leftDepth += 1 45 | leftClosed = false 46 | } 47 | } else if (leftType === 'text') { 48 | if (!leftClosed) leftDepth += 1 49 | } else if (leftType === 'close') { 50 | leftDepth -= 1 51 | leftClosed = true 52 | } 53 | 54 | if (rightType === 'open') { 55 | if (rightString !== '' && !matchOpenTags.test(rightString)) { 56 | rightDepth += 1 57 | rightClosed = false 58 | } 59 | } else if (rightType === 'text') { 60 | if (!rightClosed) rightDepth += 1 61 | } else if (rightType === 'close') { 62 | rightDepth -= 1 63 | rightClosed = true 64 | } 65 | 66 | if (leftType !== 'text') leftAttrs = getAttributes(leftString) 67 | if (rightType !== 'text') rightAttrs = getAttributes(rightString) 68 | 69 | leftFmt = leftString 70 | for (var j = 0; j < leftDepth; j++) { 71 | leftFmt = indentChars + ' ' + leftFmt 72 | } 73 | 74 | rightFmt = rightString 75 | for (var k = 0; k < rightDepth; k++) { 76 | rightFmt = indentChars + ' ' + rightFmt 77 | } 78 | 79 | // if attributes aren't the same, just compare the two versions 80 | if (!shallowEqual(leftAttrs, rightAttrs) || leftType !== rightType || 81 | leftType === 'text' || rightType === 'text') { 82 | _assert.equal(leftString, rightString, rightFmt) 83 | } else { 84 | leftTag = leftString.match(/^<[/]{0,1}(\w*)/)[1] 85 | rightTag = rightString.match(/^<[/]{0,1}(\w*)/)[1] 86 | _assert.equal(leftTag, rightTag, rightFmt) 87 | } 88 | } 89 | } 90 | 91 | function getAttributes (str) { 92 | var attrs = str.match(matchAttributes) 93 | if (!attrs) return {} 94 | return attrs.reduce(function (kv, pair) { 95 | var arr = pair.split('=') 96 | var key = arr[0] 97 | var val = arr[1] 98 | if (val) val = val.replace(/['"]/g, '') 99 | kv[key] = val 100 | return kv 101 | }, {}) 102 | } 103 | -------------------------------------------------------------------------------- /lib/tokenize.js: -------------------------------------------------------------------------------- 1 | // synchronous adaptation of: 2 | // https://github.com/substack/html-tokenize 3 | 4 | module.exports = tokenize 5 | 6 | var codes = { 7 | lt: '<'.charCodeAt(0), 8 | gt: '>'.charCodeAt(0), 9 | slash: '/'.charCodeAt(0), 10 | dquote: '"'.charCodeAt(0), 11 | squote: "'".charCodeAt(0), 12 | equal: '='.charCodeAt(0) 13 | } 14 | 15 | var strings = { 16 | endScript: Buffer.from(''), 21 | cdata: Buffer.from('') 23 | } 24 | 25 | var states = { 26 | 'TagNameState': 1, 27 | 'AttributeNameState': 2, 28 | 'BeforeAttributeValueState': 3, 29 | 'AttributeValueState': 4 30 | } 31 | 32 | function tokenize (str) { 33 | var tokenizer = new Tokenize() 34 | return tokenizer.parse(str) 35 | } 36 | 37 | function Tokenize () { 38 | this.state = 'text' 39 | this.tagState = null 40 | this.quoteState = null 41 | this.raw = null 42 | this.buffers = [] 43 | this._last = [] 44 | this._result = [] 45 | } 46 | 47 | Tokenize.prototype.parse = function (str) { 48 | if (typeof str === 'string') str = Buffer.from(str) 49 | this._parse(str) 50 | return this._result 51 | } 52 | 53 | Tokenize.prototype._parse = function (buf) { 54 | var i = 0 55 | var offset = 0 56 | 57 | if (this._prev) { 58 | buf = Buffer.concat([ this._prev, buf ]) 59 | i = this._prev.length - 1 60 | offset = this._offset 61 | this._prev = null 62 | this._offset = 0 63 | } 64 | 65 | for (; i < buf.length; i++) { 66 | var b = buf[i] 67 | this._last.push(b) 68 | if (this._last.length > 9) this._last.shift() 69 | // detect end of raw character mode (comment, script,..) 70 | if (this.raw) { 71 | var parts = this._testRaw(buf, offset, i) 72 | if (parts) { 73 | this._last.push([ 'text', parts[0] ]) 74 | 75 | if (this.raw === strings.endComment || 76 | this.raw === strings.endCdata) { 77 | this.state = 'text' 78 | this.buffers = [] 79 | this._last.push([ 'close', parts[1] ]) 80 | } else { 81 | this.state = 'open' 82 | this.buffers = [ parts[1] ] 83 | } 84 | 85 | this.raw = null 86 | offset = i + 1 87 | } 88 | // ask for more data if last byte is '<' 89 | } else if (this.state === 'text' && b === codes.lt && 90 | i === buf.length - 1) { 91 | this._prev = buf 92 | this._offset = offset 93 | return 94 | // detect a tag opening 95 | } else if (this.state === 'text' && b === codes.lt && 96 | !isWhiteSpace(buf[i + 1])) { 97 | if (i > 0 && i - offset > 0) { 98 | this.buffers.push(buf.slice(offset, i)) 99 | } 100 | offset = i 101 | this.state = 'open' 102 | this.tagState = states.TagNameState 103 | this._pushState('text') 104 | } else if ( 105 | this.tagState === states.TagNameState && 106 | isWhiteSpace(b) 107 | ) { 108 | this.tagState = states.AttributeNameState 109 | } else if ( 110 | this.tagState === states.AttributeNameState && 111 | b === codes.equal 112 | ) { 113 | this.tagState = states.BeforeAttributeValueState 114 | } else if ( 115 | this.tagState === states.BeforeAttributeValueState && 116 | isWhiteSpace(b) 117 | ) {} else if ( 118 | this.tagState === states.BeforeAttributeValueState && 119 | b !== codes.gt 120 | ) { 121 | this.tagState = states.AttributeValueState 122 | if (b === codes.dquote) { 123 | this.quoteState = 'double' 124 | } else if (b === codes.squote) { 125 | this.quoteState = 'single' 126 | } else { 127 | this.quoteState = null 128 | } 129 | } else if ( 130 | this.tagState === states.AttributeValueState && 131 | !this.quoteState && 132 | isWhiteSpace(b) 133 | ) { 134 | this.tagState = states.AttributeNameState 135 | } else if ( 136 | this.tagState === states.AttributeValueState && 137 | this.quoteState === 'double' && 138 | b === codes.dquote 139 | ) { 140 | this.quoteState = null 141 | this.tagState = states.AttributeNameState 142 | } else if ( 143 | this.tagState === states.AttributeValueState && 144 | this.quoteState === 'single' && 145 | b === codes.squote 146 | ) { 147 | this.quoteState = null 148 | this.tagState = states.AttributeNameState 149 | } else if (this.state === 'open' && b === codes.gt && !this.quoteState) { 150 | this.buffers.push(buf.slice(offset, i + 1)) 151 | offset = i + 1 152 | this.state = 'text' 153 | this.tagState = null 154 | if (this._getChar(1) === codes.slash) { 155 | this._pushState('close') 156 | } else { 157 | var tag = this._getTag() 158 | if (tag === 'script') this.raw = strings.endScript 159 | if (tag === 'style') this.raw = strings.endStyle 160 | if (tag === 'title') this.raw = strings.endTitle 161 | this._pushState('open') 162 | } 163 | } else if (this.state === 'open' && compare(this._last, strings.comment)) { 164 | this.buffers.push(buf.slice(offset, i + 1)) 165 | offset = i + 1 166 | this.state = 'text' 167 | this.raw = strings.endComment 168 | this._pushState('open') 169 | } else if (this.state === 'open' && compare(this._last, strings.cdata)) { 170 | this.buffers.push(buf.slice(offset, i + 1)) 171 | offset = i + 1 172 | this.state = 'text' 173 | this.raw = strings.endCdata 174 | this._pushState('open') 175 | } 176 | } 177 | if (offset < buf.length) this.buffers.push(buf.slice(offset)) 178 | } 179 | 180 | Tokenize.prototype._flush = function (next) { 181 | if (this.state === 'text') this._pushState('text') 182 | } 183 | 184 | Tokenize.prototype._pushState = function (ev) { 185 | if (this.buffers.length === 0) return 186 | var buf = Buffer.concat(this.buffers) 187 | this.buffers = [] 188 | this._result.push([ ev, buf ]) 189 | } 190 | 191 | Tokenize.prototype._getChar = function (i) { 192 | var offset = 0 193 | for (var j = 0; j < this.buffers.length; j++) { 194 | var buf = this.buffers[j] 195 | if (offset + buf.length > i) { 196 | return buf[i - offset] 197 | } 198 | offset += buf 199 | } 200 | } 201 | 202 | Tokenize.prototype._getTag = function () { 203 | var offset = 0 204 | var tag = '' 205 | for (var j = 0; j < this.buffers.length; j++) { 206 | var buf = this.buffers[j] 207 | for (var k = 0; k < buf.length; k++) { 208 | if (offset === 0 && k === 0) continue 209 | var c = String.fromCharCode(buf[k]) 210 | if (/[^\w-!\[\]]/.test(c)) { // eslint-disable-line no-useless-escape 211 | return tag.toLowerCase() 212 | } else tag += c 213 | } 214 | offset += buf.length 215 | } 216 | } 217 | 218 | Tokenize.prototype._testRaw = function (buf, offset, index) { 219 | var raw = this.raw 220 | var last = this._last 221 | if (!compare(last, raw)) return 222 | 223 | this.buffers.push(buf.slice(offset, index + 1)) 224 | buf = Buffer.concat(this.buffers) 225 | var k = buf.length - raw.length 226 | return [ buf.slice(0, k), buf.slice(k) ] 227 | } 228 | 229 | function compare (a, b) { 230 | if (a.length < b.length) return false 231 | for (var i = a.length - 1, j = b.length - 1; i >= 0 && j >= 0; i--, j--) { 232 | if (lower(a[i]) !== lower(b[j])) return false 233 | } 234 | return true 235 | } 236 | 237 | function lower (n) { 238 | if (n >= 65 && n <= 90) return n + 32 239 | return n 240 | } 241 | 242 | function isWhiteSpace (b) { 243 | return b === 0x20 || b === 0x09 || b === 0x0A || b === 0x0C || b === 0x0D 244 | } 245 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assert-html", 3 | "description": "Assert two HTML strings are equal", 4 | "repository": "yoshuawuyts/assert-html", 5 | "version": "1.1.5", 6 | "scripts": { 7 | "deps": "dependency-check . && dependency-check . --extra --no-dev", 8 | "start": "node .", 9 | "test": "standard && npm run deps && node test" 10 | }, 11 | "dependencies": { 12 | "shallow-equal": "^1.0.0" 13 | }, 14 | "devDependencies": { 15 | "bel": "^5.0.0", 16 | "dependency-check": "^2.8.0", 17 | "standard": "^10.0.2", 18 | "tape": "^4.6.3" 19 | }, 20 | "keywords": [ 21 | "assert", 22 | "html", 23 | "node" 24 | ], 25 | "license": "MIT" 26 | } 27 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var tape = require('tape') 3 | var html = require('bel') 4 | 5 | var assertHtml = require('./') 6 | 7 | tape('compare two DOM strings: OK', function (_assert) { 8 | var a, b 9 | 10 | a = html`
hello planet
`.toString() 11 | b = html`
hello planet
`.toString() 12 | 13 | _assert.doesNotThrow(function () { 14 | assertHtml(assert, a, b) 15 | }) 16 | 17 | _assert.end() 18 | }) 19 | 20 | tape('compare two DOM strings: NOT OK', function (_assert) { 21 | var a, b 22 | 23 | a = html`
hello planet
`.toString() 24 | b = html`
earth
`.toString() 25 | 26 | _assert.throws(function () { 27 | assertHtml(assert, a, b) 28 | }) 29 | 30 | _assert.end() 31 | }) 32 | 33 | tape('allow random order', function (_assert) { 34 | var a, b 35 | a = html`
hello planet
`.toString() 36 | b = html`
hello planet
`.toString() 37 | 38 | _assert.doesNotThrow(function () { 39 | assertHtml(assert, a, b) 40 | }) 41 | 42 | _assert.end() 43 | }) 44 | 45 | tape('allow attributes with only keys', function (_assert) { 46 | var a, b 47 | 48 | a = html`
hello planet
`.toString() 49 | b = html`
hello planet
`.toString() 50 | 51 | _assert.doesNotThrow(function () { 52 | assertHtml(assert, a, b) 53 | }) 54 | 55 | _assert.end() 56 | }) 57 | 58 | tape('allow title tag', function (_assert) { 59 | var a, b 60 | 61 | a = html`Hi hello how are you?`.toString() 62 | b = html`Hi hello how are you?`.toString() 63 | 64 | _assert.doesNotThrow(function () { 65 | assertHtml(assert, a, b) 66 | }) 67 | 68 | _assert.end() 69 | }) 70 | --------------------------------------------------------------------------------