diff --git a/lib/index.js b/lib/index.js index 057dee7c..a40bfc81 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,4 @@ var arrayChanges = require('array-changes'); -var extend = require('extend'); // From html-minifier var enumeratedAttributeValues = { @@ -33,6 +32,14 @@ function styleStringToObject(str) { return styles; } +function getClassNamesFromAttributeValue(attributeValue) { + var classNames = attributeValue.split(/\s+/); + if (classNames.length === 1 && classNames[0] === '') { + classNames.pop(); + } + return classNames; +} + function getAttributes(element) { var attrs = element.attributes; var result = {}; @@ -65,23 +72,43 @@ function isVoidElement(elementName) { return (/(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)/i).test(elementName); } +function writeAttributeToMagicPen(output, attributeName, value) { + output['prism:attr-name'](attributeName); + if (!isBooleanAttribute(attributeName)) { + if (attributeName === 'class') { + value = value.join(' '); + } else if (attributeName === 'style') { + value = Object.keys(value).map(function (cssProp) { + return cssProp + ': ' + value[cssProp]; + }).join('; '); + } + output['prism:punctuation']('="'); + output['prism:attr-value'](value.replace(/&/g, '&').replace(/"/g, '"')); + output['prism:punctuation']('"'); + } +} + +function stringifyAttribute(attributeName, value) { + if (isBooleanAttribute(attributeName)) { + return attributeName; + } else if (attributeName === 'class') { + return 'class="' + value.join(' ') + '"'; // FIXME: entitify + } else if (attributeName === 'style') { + return 'style="' + Object.keys(value).map(function (cssProp) { + return [cssProp, value[cssProp]].join(': '); // FIXME: entitify + }).join('; ') + '"'; + } else { + return attributeName + '="' + value.replace(/&/g, '&').replace(/"/g, '"') + '"'; + } +} + function stringifyStartTag(element) { var elementName = element.nodeName.toLowerCase(); var str = '<' + elementName; var attrs = getCanonicalAttributes(element); Object.keys(attrs).forEach(function (key) { - if (isBooleanAttribute(key)) { - str += ' ' + key; - } else if (key === 'class') { - str += ' class="' + attrs[key].join(' ') + '"'; - } else if (key === 'style') { - str += ' class="' + Object.keys(attrs[key]).map(function (cssProp) { - return [cssProp, attrs[key][cssProp]].join(': '); - }).join('; ') + '"'; - } else { - str += ' ' + key + '="' + attrs[key].replace(/&/g, '&').replace(/"/g, '"') + '"'; - } + str += ' ' + stringifyAttribute(key, attrs[key]); }); str += '>'; @@ -138,7 +165,7 @@ function diffNodeLists(actual, expected, output, diff, inspect, equal) { module.exports = { name: 'unexpected-dom', installInto: function (expect) { - + var topLevelExpect = expect; expect.addType({ name: 'DOMNode', base: 'object', @@ -350,74 +377,184 @@ module.exports = { } }); - expect.addAssertion('HTMLElement', 'to [only] have (attribute|attributes)', function (expect, subject, cmp) { - var attrs = getAttributes(subject); - var cmpClasses; - var cmpStyles; - - if (typeof cmp === 'string') { - cmp = Array.prototype.slice.call(arguments, 2); + expect.addAssertion('HTMLElement', 'to [only] have (class|classes)', function (expect, subject, value) { + var flags = this.flags; + if (flags.only) { + return expect(subject, 'to have attributes', { + class: function (className) { + var actualClasses = getClassNamesFromAttributeValue(className); + if (typeof value === 'string') { + value = getClassNamesFromAttributeValue(value); + } + if (flags.only) { + return topLevelExpect(actualClasses.sort(), 'to equal', value.sort()); + } else { + return topLevelExpect.apply(topLevelExpect, [actualClasses, 'to contain'].concat(value)); + } + } + }); + } else { + return expect(subject, 'to have attributes', { class: value }); } + }); - if (Array.isArray(cmp)) { - expect(attrs, 'to [only] have keys', cmp); - } else if (typeof cmp === 'object') { - this.flags.exhaustively = this.flags.only; - - var comparator = extend({}, cmp); + expect.addAssertion('HTMLElement', 'to [only] have (attribute|attributes)', function (expect, subject, value) { + var flags = this.flags; + var attrs = getAttributes(subject); - if (cmp['class']) { - if (typeof cmp['class'] === 'string') { - cmpClasses = cmp['class'].split(' '); - } else { - cmpClasses = cmp['class']; - } + if (typeof value === 'string') { + value = Array.prototype.slice.call(arguments, 2); + } + var expectedValueByAttributeName = {}; + if (Array.isArray(value)) { + value.forEach(function (attributeName) { + expectedValueByAttributeName[attributeName] = true; + }); + } else if (value && typeof value === 'object') { + expectedValueByAttributeName = value; + } else { + throw new Error('to have attributes: Argument must be a string, an array, or an object'); + } + var expectedValueByLowerCasedAttributeName = {}, + expectedAttributeNames = []; + Object.keys(expectedValueByAttributeName).forEach(function (attributeName) { + var lowerCasedAttributeName = attributeName.toLowerCase(); + expectedAttributeNames.push(lowerCasedAttributeName); + if (expectedValueByLowerCasedAttributeName.hasOwnProperty(lowerCasedAttributeName)) { + throw new Error('Duplicate expected attribute with different casing: ' + attributeName); } - - if (cmp.style) { - if (typeof cmp.style === 'string') { - cmpStyles = styleStringToObject(cmp.style); - } else { - cmpStyles = cmp.style; + expectedValueByLowerCasedAttributeName[lowerCasedAttributeName] = expectedValueByAttributeName[attributeName]; + }); + expectedValueByAttributeName = expectedValueByLowerCasedAttributeName; + + var promiseByKey = { + presence: expect.promise(function () { + var attributeNamesExpectedToBeDefined = []; + expectedAttributeNames.forEach(function (attributeName) { + if (typeof expectedValueByAttributeName[attributeName] === 'undefined') { + expect(attrs, 'not to have key', attributeName); + } else { + attributeNamesExpectedToBeDefined.push(attributeName); + expect(attrs, 'to have key', attributeName); + } + }); + if (flags.only) { + expect(Object.keys(attrs).sort(), 'to equal', attributeNamesExpectedToBeDefined.sort()); } + }), + attributes: {} + }; + + expectedAttributeNames.forEach(function (attributeName) { + var attributeValue = subject.getAttribute(attributeName); + var expectedAttributeValue = expectedValueByAttributeName[attributeName]; + promiseByKey.attributes[attributeName] = expect.promise(function () { + if (attributeName === 'class' && (typeof expectedAttributeValue === 'string' || Array.isArray(expectedAttributeValue))) { + var actualClasses = getClassNamesFromAttributeValue(attributeValue); + var expectedClasses = expectedAttributeValue; + if (typeof expectedClasses === 'string') { + expectedClasses = getClassNamesFromAttributeValue(expectedAttributeValue); + } + if (flags.only) { + return topLevelExpect(actualClasses.sort(), 'to equal', expectedClasses.sort()); + } else { + return topLevelExpect.apply(topLevelExpect, [actualClasses, 'to contain'].concat(expectedClasses)); + } + } else if (attributeName === 'style') { + var expectedStyleObj; + if (typeof expectedValueByAttributeName.style === 'string') { + expectedStyleObj = styleStringToObject(expectedValueByAttributeName.style); + } else { + expectedStyleObj = expectedValueByAttributeName.style; + } - if (this.flags.exhaustively) { - comparator.style = expect.it('to exhaustively satisfy', cmpStyles); + if (flags.only) { + return topLevelExpect(attrs.style, 'to exhaustively satisfy', expectedStyleObj); + } else { + return topLevelExpect(attrs.style, 'to satisfy', expectedStyleObj); + } + } else if (expectedAttributeValue === true) { + expect(subject.hasAttribute(attributeName), 'to be true'); } else { - comparator.style = expect.it('to satisfy', cmpStyles); + return topLevelExpect(attributeValue, 'to satisfy', expectedAttributeValue); } - } - - if (this.flags.exhaustively) { - if (cmp['class']) { - var cmpClassesCopy = cmpClasses.slice(); - cmpClasses = []; - attrs['class'].forEach(function (className) { - var idx = cmpClassesCopy.indexOf(className); - - if (idx !== -1) { - cmpClasses.push(cmpClassesCopy.splice(idx, 1)[0]); - } - }); - - cmpClasses = cmpClasses.concat(cmpClassesCopy); - - if (cmp['class']) { - comparator['class'] = cmpClasses; + }); + }); + + return expect.promise.all(promiseByKey).caught(function () { + return expect.promise.settle(promiseByKey).then(function () { + expect.fail({ + diff: function (output, diff, inspect, equal) { + output['prism:punctuation']('<')['prism:tag'](subject.nodeName.toLowerCase()); + var failedAttributeNames = []; + var isFirst = true; + Object.keys(attrs).forEach(function (attributeName) { + var lowerCaseAttributeName = attributeName.toLowerCase(); + var promise = promiseByKey.attributes[lowerCaseAttributeName]; + if ((promise && promise.isFulfilled()) || (!promise && (!flags.only || expectedAttributeNames.indexOf(lowerCaseAttributeName) !== -1))) { + output.sp(); + writeAttributeToMagicPen(output, attributeName, attrs[attributeName]); + isFirst = false; + } else { + failedAttributeNames.push(attributeName); + } + }); + failedAttributeNames.forEach(function (attributeName) { + var lowerCaseAttributeName = attributeName.toLowerCase(); + var promise = promiseByKey.attributes[lowerCaseAttributeName]; + if (isFirst) { + output.sp(); + } else { + output + .nl() + .sp(2 + subject.nodeName.length); + } + writeAttributeToMagicPen(output, attributeName, attrs[attributeName]); + output + .sp() + .annotationBlock(function () { + if (promise) { + this.append(promise.reason().output); // v8: getErrorMessage + } else { + // flags.only === true + this.error('should be removed'); + } + }); + isFirst = false; + }); + expectedAttributeNames.forEach(function (attributeName) { + if (!subject.hasAttribute(attributeName)) { + var promise = promiseByKey.attributes[attributeName]; + if (!promise || promise.isRejected()) { + var err = promise && promise.reason(); + output + .nl() + .sp(2 + subject.nodeName.length) + .annotationBlock(function () { + this + .error('missing') + .sp() + ['prism:attr-name'](attributeName, 'html'); + if (expectedValueByAttributeName[attributeName] !== true) { + this + .sp() + .error((err && err.label) || 'should satisfy') // v8: err.getLabel() + .sp() + .append(inspect(expectedValueByAttributeName[attributeName])); + } + }); + } + } + }); + output.nl()['prism:punctuation']('>'); + return { + inline: true, + diff: output + }; } - } - - return expect(attrs, 'to [exhaustively] satisfy', comparator); - } else { - if (cmp['class']) { - comparator['class'] = expect.it.apply(null, ['to contain'].concat(cmpClasses)); - } - - return expect(attrs, 'to satisfy', comparator); - } - } else { - throw new Error('Please supply either strings, array, or object'); - } + }); + }); + }); }); expect.addAssertion('HTMLElement', 'to have [no] (child|children)', function (expect, subject, query, cmp) { diff --git a/test/unexpected-dom.js b/test/unexpected-dom.js index 21631f41..447f3776 100644 --- a/test/unexpected-dom.js +++ b/test/unexpected-dom.js @@ -118,6 +118,102 @@ describe('unexpected-dom', function () { }); }); + describe('to have class', function () { + describe('with a single class passed as a string', function () { + it('should succeed', function () { + body.innerHTML = ''; + expect(body.firstChild, 'to have class', 'bar'); + }); + + it('should fail with a diff', function () { + body.innerHTML = ''; + expect(function () { + expect(body.firstChild, 'to have class', 'quux'); + }, 'to throw', + 'expected to have class \'quux\'\n' + + '\n' + + ''; + expect(body.firstChild, 'to have classes', ['foo', 'bar']); + }); + + it('should fail with a diff', function () { + body.innerHTML = ''; + expect(function () { + expect(body.firstChild, 'to have classes', ['quux', 'bar']); + }, 'to throw', + 'expected to have classes [ \'quux\', \'bar\' ]\n' + + '\n' + + ''; + expect(body.firstChild, 'to only have class', 'bar'); + }); + + it('should fail with a diff', function () { + body.innerHTML = ''; + expect(function () { + expect(body.firstChild, 'to only have class', 'quux'); + }, 'to throw', + 'expected to only have class \'quux\'\n' + + '\n' + + ''; + expect(body.firstChild, 'to only have classes', ['foo', 'bar']); + }); + + it('should fail with a diff', function () { + body.innerHTML = ''; + expect(function () { + expect(body.firstChild, 'to only have classes', ['quux', 'bar']); + }, 'to throw', + 'expected \n' + + 'to only have classes [ \'bar\', \'quux\' ]\n' + + '\n' + + ' to only have attributes \'id\'' + 'expected to only have attributes \'id\'\n' + + '\n' + + ' to have attributes \'id\', \'foo\''); + 'expected to have attributes \'id\', \'foo\'\n' + + '\n' + + ' to only have attributes [ \'id\' ]'); + }, 'to throw', + 'expected to only have attributes [ \'id\' ]\n' + + '\n' + + ' to have attributes [ \'id\', \'foo\' ]'); + }, 'to throw', + 'expected to have attributes [ \'id\', \'foo\' ]\n' + + '\n' + + '