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' +
+ '