unexpected
Version:
Extensible BDD assertion toolkit
914 lines (821 loc) • 32.9 kB
JavaScript
var utils = require('./utils');
var isRegExp = utils.isRegExp;
var leftPad = utils.leftPad;
var extend = utils.extend;
var arrayDiff = require('arraydiff');
var leven = require('leven');
module.exports = function (expect) {
expect.addType({
name: 'wrapperObject',
identify: false,
equal: function (a, b, equal) {
return a === b || equal(this.unwrap(a), this.unwrap(b));
},
inspect: function (value, depth, output, inspect) {
output.append(this.prefix(output.clone()));
output.append(inspect(this.unwrap(value)));
output.append(this.suffix(output.clone()));
},
diff: function (actual, expected, output, diff, inspect) {
actual = this.unwrap(actual);
expected = this.unwrap(expected);
var comparison = diff(actual, expected);
var prefixOutput = this.prefix(output.clone());
var suffixOutput = this.suffix(output.clone());
if (comparison && comparison.inline) {
return {
inline: true,
diff: output.append(prefixOutput).append(comparison.diff).append(suffixOutput)
};
} else {
return {
inline: true,
diff: output.append(prefixOutput).nl()
.indentLines()
.i().block(function () {
this.append(inspect(actual)).sp().annotationBlock(function () {
this.shouldEqualError(expected, inspect);
if (comparison) {
this.nl().append(comparison.diff);
}
});
}).nl()
.outdentLines()
.append(suffixOutput)
};
}
}
});
expect.addType({
name: 'object',
identify: function (obj) {
return obj && (typeof obj === 'object' || typeof obj === 'function');
},
getKeys: Object.keys,
equal: function (a, b, equal) {
if (a === b) {
return true;
}
if (b.constructor !== a.constructor) {
return false;
}
var actualKeys = utils.getKeysOfDefinedProperties(a),
expectedKeys = utils.getKeysOfDefinedProperties(b),
key;
// having the same number of owned properties (keys incorporates hasOwnProperty)
if (actualKeys.length !== expectedKeys.length) {
return false;
}
//the same set of keys (although not necessarily the same order),
actualKeys.sort();
expectedKeys.sort();
// cheap key test
for (var i = 0; i < actualKeys.length; i += 1) {
if (actualKeys[i] !== expectedKeys[i]) {
return false;
}
}
//equivalent values for every corresponding key, and
// possibly expensive deep test
for (var j = 0; j < actualKeys.length; j += 1) {
key = actualKeys[j];
if (!equal(a[key], b[key])) {
return false;
}
}
return true;
},
inspect: function (obj, depth, output, inspect) {
var keys = Object.keys(obj);
if (keys.length === 0) {
return utils.wrapConstructorNameAroundOutput(output.text('{}'), obj);
}
var inspectedItems = keys.map(function (key, index) {
var lastIndex = index === keys.length - 1;
var hasGetter = obj.__lookupGetter__ && obj.__lookupGetter__(key);
var hasSetter = obj.__lookupGetter__ && obj.__lookupSetter__(key);
var propertyOutput = output.clone();
if (hasSetter && !hasGetter) {
propertyOutput.text('set').sp();
}
propertyOutput.key(key);
propertyOutput.text(':');
// Inspect the setter function if there's no getter:
var value = (hasSetter && !hasGetter) ? hasSetter : obj[key];
var inspectedValue = inspect(value);
if (!lastIndex) {
inspectedValue.text(',');
}
if (value && value._expectIt) {
propertyOutput.sp().block(inspectedValue);
} else {
propertyOutput.sp().append(inspectedValue);
}
if (hasGetter && hasSetter) {
propertyOutput.sp().jsComment('/* getter/setter */');
} else if (hasGetter) {
propertyOutput.sp().jsComment('/* getter */');
}
return propertyOutput;
});
var width = 0;
var multipleLines = inspectedItems.some(function (o) {
var size = o.size();
width += size.width;
return width > 50 || size.height > 1;
});
if (multipleLines) {
output.text('{').nl().indentLines();
inspectedItems.forEach(function (inspectedItem, index) {
output.i().block(inspectedItem).nl();
});
output.outdentLines().text('}');
} else {
output.text('{ ');
inspectedItems.forEach(function (inspectedItem, index) {
output.append(inspectedItem);
var lastIndex = index === inspectedItems.length - 1;
if (!lastIndex) {
output.sp();
}
});
output.text(' }');
}
return utils.wrapConstructorNameAroundOutput(output, obj);
},
diff: function (actual, expected, output, diff, inspect, equal) {
if (actual.constructor !== expected.constructor) {
return {
diff: output.text('Mismatching constructors ')
.text(actual.constructor && actual.constructor.name || actual.constructor)
.text(' should be ').text(expected.constructor && expected.constructor.name || expected.constructor),
inline: false
};
}
var result = {
diff: output,
inline: true
};
var keyIndex = {};
Object.keys(actual).concat(Object.keys(expected)).forEach(function (key) {
if (!(key in result)) {
keyIndex[key] = key;
}
});
var keys = Object.keys(keyIndex);
output.text('{').nl().indentLines();
keys.forEach(function (key, index) {
output.i().block(function () {
var valueOutput;
var annotation = output.clone();
var conflicting = !equal(actual[key], expected[key]);
var isInlineDiff = false;
if (conflicting) {
if (!(key in expected)) {
annotation.error('should be removed');
isInlineDiff = true;
} else {
var keyDiff = diff(actual[key], expected[key]);
if (!keyDiff || (keyDiff && !keyDiff.inline)) {
annotation.shouldEqualError(expected[key], inspect);
if (keyDiff) {
annotation.nl().append(keyDiff.diff);
}
} else {
isInlineDiff = true;
valueOutput = keyDiff.diff;
}
}
} else {
isInlineDiff = true;
}
var last = index === keys.length - 1;
if (!valueOutput) {
valueOutput = inspect(actual[key], conflicting ? Infinity : 1);
}
this.key(key);
this.text(':').sp();
valueOutput.text(last ? '' : ',');
if (isInlineDiff) {
this.append(valueOutput);
} else {
this.block(valueOutput);
}
if (!annotation.isEmpty()) {
this.sp().annotationBlock(annotation);
}
}).nl();
});
output.outdentLines().text('}');
result.diff = utils.wrapConstructorNameAroundOutput(output, actual);
return result;
}
});
function structurallySimilar(a, b) {
var typeA = typeof a;
var typeB = typeof b;
if (typeA !== typeB) {
return false;
}
if (typeA === 'string') {
return leven(a, b) < a.length / 2;
}
if (typeA !== 'object' || !a) {
return false;
}
if (utils.isArray(a) && utils.isArray(b)) {
return true;
}
var aKeys = Object.keys(a);
var bKeys = Object.keys(b);
var numberOfSimilarKeys = 0;
var requiredSimilarKeys = Math.round(Math.max(aKeys.length, bKeys.length) / 2);
return aKeys.concat(bKeys).some(function (key) {
if (key in a && key in b) {
numberOfSimilarKeys += 1;
}
return numberOfSimilarKeys >= requiredSimilarKeys;
});
}
expect.addType({
name: 'array-like',
base: 'object',
identify: false,
getKeys: function (obj) {
var keys = new Array(obj.length);
for (var i = 0 ; i < obj.length ; i += 1) {
keys[i] = i;
}
return keys;
},
equal: function (a, b, equal) {
if (a === b) {
return true;
} else if (a.constructor === b.constructor && a.length === b.length) {
for (var i = 0; i < a.length; i += 1) {
if (!equal(a[i], b[i])) {
return false;
}
}
return true;
} else {
return false;
}
},
prefix: function (output) {
return output.text('[');
},
suffix: function (output) {
return output.text(']');
},
inspect: function (arr, depth, output, inspect) {
var prefixOutput = this.prefix(output.clone(), arr);
var suffixOutput = this.suffix(output.clone(), arr);
if (arr.length === 0) {
return output.append(prefixOutput).append(suffixOutput);
}
if (depth === 1) {
return output.append(prefixOutput).text('...').append(suffixOutput);
}
var inspectedItems = new Array(arr.length);
for (var i = 0; i < arr.length; i += 1) {
if (i in arr) {
inspectedItems[i] = inspect(arr[i]);
} else {
inspectedItems[i] = output.clone();
}
}
var width = 0;
var multipleLines = inspectedItems.some(function (o) {
var size = o.size();
width += size.width;
return width > 50 || o.height > 1;
});
inspectedItems.forEach(function (inspectedItem, index) {
var lastIndex = index === inspectedItems.length - 1;
if (!lastIndex) {
inspectedItem.text(',');
}
});
if (multipleLines) {
output.append(prefixOutput).nl().indentLines();
inspectedItems.forEach(function (inspectedItem, index) {
output.i().block(inspectedItem).nl();
});
output.outdentLines().append(suffixOutput);
} else {
output.append(prefixOutput).sp();
inspectedItems.forEach(function (inspectedItem, index) {
output.append(inspectedItem);
var lastIndex = index === inspectedItems.length - 1;
if (!lastIndex) {
output.sp();
}
});
output.sp().append(suffixOutput);
}
},
diffLimit: 512,
diff: function (actual, expected, output, diff, inspect, equal) {
var result = {
diff: output,
inline: true
};
if (Math.max(actual.length, expected.length) > this.diffLimit) {
result.diff.jsComment('Diff suppressed due to size > ' + this.diffLimit);
return result;
}
if (actual.constructor !== expected.constructor) {
return this.baseType.diff(actual, expected);
}
var mutatedArray = new Array(actual.length);
for (var k = 0; k < actual.length; k += 1) {
mutatedArray[k] = {
type: 'similar',
value: actual[k]
};
}
if (mutatedArray.length > 0) {
mutatedArray[mutatedArray.length - 1].last = true;
}
var itemsDiff = arrayDiff(actual, expected, function (a, b) {
return equal(a, b) || structurallySimilar(a, b);
});
var removeTable = [];
function offsetIndex(index) {
return index + (removeTable[index - 1] || 0);
}
var removes = itemsDiff.filter(function (diffItem) {
return diffItem.type === 'remove';
});
var removesByIndex = {};
var removedItems = 0;
removes.forEach(function (diffItem) {
var removeIndex = removedItems + diffItem.index;
mutatedArray.slice(removeIndex, diffItem.howMany + removeIndex).forEach(function (v) {
v.type = 'remove';
});
removedItems += diffItem.howMany;
removesByIndex[diffItem.index] = removedItems;
});
function updateRemoveTable() {
removedItems = 0;
actual.forEach(function (_, index) {
removedItems += removesByIndex[index] || 0;
removeTable[index] = removedItems;
});
}
updateRemoveTable();
var moves = itemsDiff.filter(function (diffItem) {
return diffItem.type === 'move';
});
var movedItems = 0;
moves.forEach(function (diffItem) {
var moveFromIndex = offsetIndex(diffItem.from);
var removed = mutatedArray.slice(moveFromIndex, diffItem.howMany + moveFromIndex);
var added = removed.map(function (v) {
return utils.extend({}, v, { type: 'insert' });
});
removed.forEach(function (v) {
v.type = 'remove';
});
Array.prototype.splice.apply(mutatedArray, [offsetIndex(diffItem.to), 0].concat(added));
movedItems += diffItem.howMany;
removesByIndex[diffItem.from] = movedItems;
updateRemoveTable();
});
var inserts = itemsDiff.filter(function (diffItem) {
return diffItem.type === 'insert';
});
inserts.forEach(function (diffItem) {
var added = new Array(diffItem.values.length);
for (var i = 0 ; i < diffItem.values.length ; i += 1) {
added[i] = {
type: 'insert',
value: diffItem.values[i]
};
}
Array.prototype.splice.apply(mutatedArray, [offsetIndex(diffItem.index), 0].concat(added));
});
var offset = 0;
mutatedArray.forEach(function (diffItem, index) {
var type = diffItem.type;
if (type === 'remove') {
offset -= 1;
} else if (type === 'similar') {
diffItem.expected = expected[offset + index];
}
});
var conflicts = mutatedArray.reduce(function (conflicts, item) {
return item.type === 'similar' ? conflicts : conflicts + 1;
}, 0);
for (var i = 0, c = 0; i < Math.max(actual.length, expected.length) && c <= conflicts; i += 1) {
var expectedType = typeof expected[i];
var actualType = typeof actual[i];
if (
actualType !== expectedType ||
((actualType === 'object' || actualType === 'string') && !structurallySimilar(actual[i], expected[i])) ||
(actualType !== 'object' && actualType !== 'string' && !equal(actual[i], expected[i]))
) {
c += 1;
}
}
if (c <= conflicts) {
mutatedArray = [];
var j;
for (j = 0; j < Math.min(actual.length, expected.length); j += 1) {
mutatedArray.push({
type: 'similar',
value: actual[j],
expected: expected[j]
});
}
if (actual.length < expected.length) {
for (; j < Math.max(actual.length, expected.length); j += 1) {
mutatedArray.push({
type: 'insert',
value: expected[j]
});
}
} else {
for (; j < Math.max(actual.length, expected.length); j += 1) {
mutatedArray.push({
type: 'remove',
value: actual[j]
});
}
}
mutatedArray[mutatedArray.length - 1].last = true;
}
mutatedArray.forEach(function (diffItem) {
if (diffItem.type === 'similar' && equal(diffItem.value, diffItem.expected)) {
diffItem.type = 'equal';
}
});
output.append(this.prefix(output.clone())).nl().indentLines();
mutatedArray.forEach(function (diffItem, index) {
output.i().block(function () {
var type = diffItem.type;
var last = !!diffItem.last;
if (type === 'insert') {
this.annotationBlock(function () {
this.error('missing ').block(inspect(diffItem.value));
});
} else if (type === 'remove') {
this.block(inspect(diffItem.value).text(last ? ' ' : ', ').error('// should be removed'));
} else if (type === 'equal') {
this.block(inspect(diffItem.value).text(last ? '' : ','));
} else {
var valueDiff = diff(diffItem.value, diffItem.expected);
if (valueDiff && valueDiff.inline) {
this.block(valueDiff.diff.text(last ? '' : ','));
} else if (valueDiff) {
this.block(inspect(diffItem.value).text(last ? ' ' : ', ')).annotationBlock(function () {
this.shouldEqualError(diffItem.expected, inspect).nl().append(valueDiff.diff);
});
} else {
this.block(inspect(diffItem.value).text(last ? ' ' : ', ')).annotationBlock(function () {
this.shouldEqualError(diffItem.expected, inspect);
});
}
}
}).nl();
});
output.outdentLines().append(this.suffix(output.clone()));
return result;
}
});
expect.addType({
name: 'array',
base: 'array-like',
identify: function (arr) {
return utils.isArray(arr);
}
});
expect.addType({
name: 'arguments',
base: 'array-like',
prefix: function (output) {
return output.text('arguments(', 'cyan');
},
suffix: function (output) {
return output.text(')', 'cyan');
},
identify: function (obj) {
return Object.prototype.toString.call(obj) === '[object Arguments]';
}
});
expect.addType({
base: 'object',
name: 'Error',
identify: function (value) {
return utils.isError(value);
},
getKeys: function (value) {
var keys = this.baseType.getKeys(value);
keys.push('message');
return keys;
},
unwrap: function (value) {
return extend({
message: value.message
}, value);
},
equal: function (a, b, equal) {
return a === b ||
(equal(a.message, b.message) && this.baseType.equal(a, b));
},
inspect: function (value, depth, output, inspect) {
var errorObject = this.unwrap(value);
// TODO: Inspect Error as a built-in once we have the styles defined:
output.text('Error(').append(inspect(errorObject, depth)).text(')');
},
diff: function (actual, expected, output, diff) {
var result = diff(extend({
message: actual.message
}, actual), extend({
message: expected.message
}, expected));
if (result.diff) {
result.diff = utils.wrapConstructorNameAroundOutput(result.diff, actual);
}
return result;
}
});
expect.addType({
name: 'date',
identify: function (obj) {
return Object.prototype.toString.call(obj) === '[object Date]';
},
equal: function (a, b) {
return a.getTime() === b.getTime();
},
inspect: function (date, depth, output, inspect) {
// TODO: Inspect "new" as an operator and Date as a built-in once we have the styles defined:
output.jsKeyword('new').sp().text('Date(').append(inspect(date.toUTCString()).text(')'));
}
});
expect.addType({
base: 'object',
name: 'function',
identify: function (f) {
return typeof f === 'function';
},
equal: function (a, b) {
return a === b || a.toString() === b.toString();
},
inspect: function (f, depth, output, inspect) {
var source = f.toString();
var name;
var args;
var body;
var matchSource = source.match(/^function (\w*)?\s*\(([^\)]*)\)\s*\{([\s\S]*?( *)?)\}$/);
if (matchSource) {
name = matchSource[1];
args = matchSource[2];
body = matchSource[3];
var bodyIndent = matchSource[4] || '';
// Remove leading indentation unless the function is a one-liner or it uses multiline string literals
if (/\n/.test(body) && !/\\\n/.test(body)) {
body = body.replace(new RegExp('^ {' + bodyIndent.length + '}', 'mg'), '');
}
if (!name || name === 'anonymous') {
name = '';
}
if (/^\s*\[native code\]\s*$/.test(body)) {
body = ' /* native code */ ';
} else {
body = body.replace(/^((?:.*\n){3}( *).*\n)[\s\S]*?\n((?:.*\n){3})$/, '$1$2// ... lines removed ...\n$3');
}
} else {
name = f.name || '';
args = ' /*...*/ ';
body = ' /*...*/ ';
}
output.code('function ' + name + '(' + args + ') {' + body + '}', 'javascript');
}
});
expect.addType({
base: 'function',
name: 'expect.it',
identify: function (f) {
return typeof f === 'function' && f._expectIt;
},
inspect: function (f, depth, output, inspect) {
output.text('expect.it(');
var orBranch = false;
f._expectations.forEach(function (expectation, index) {
if (expectation === f._OR) {
orBranch = true;
return;
}
if (orBranch) {
output.text(')\n .or(');
} else if (0 < index) {
output.text(')\n .and(');
}
var args = Array.prototype.slice.call(expectation);
args.forEach(function (arg, i) {
if (0 < i) {
output.text(', ');
}
output.append(inspect(arg));
});
orBranch = false;
});
output.text(')');
}
});
expect.addType({
name: 'regexp',
identify: isRegExp,
equal: function (a, b) {
return a === b || (
a.source === b.source &&
a.global === b.global &&
a.ignoreCase === b.ignoreCase &&
a.multiline === b.multiline
);
},
inspect: function (regExp, depth, output) {
output.jsRegexp(regExp);
}
});
expect.addType({
name: 'DomElement',
identify: function (value) {
return utils.isDOMElement(value);
},
inspect: function (value, depth, output) {
output.code(utils.getOuterHTML(value), 'html');
}
});
expect.addType({
name: 'binaryArray',
base: 'array-like',
digitWidth: 2,
hexDumpWidth: 16,
identify: false,
equal: function (a, b) {
if (a === b) {
return true;
}
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
},
hexDump: function (obj, maxLength) {
var hexDump = '';
if (typeof maxLength !== 'number' || maxLength === 0) {
maxLength = obj.length;
}
for (var i = 0 ; i < maxLength ; i += this.hexDumpWidth) {
if (hexDump.length > 0) {
hexDump += '\n';
}
var hexChars = '',
asciiChars = ' │';
for (var j = 0 ; j < this.hexDumpWidth ; j += 1) {
if (i + j < maxLength) {
var octet = obj[i + j];
hexChars += leftPad(octet.toString(16).toUpperCase(), this.digitWidth, '0') + ' ';
asciiChars += String.fromCharCode(octet).replace(/\n/g, '␊').replace(/\r/g, '␍');
} else if (this.digitWidth === 2) {
hexChars += ' ';
}
}
if (this.digitWidth === 2) {
hexDump += hexChars + asciiChars + '│';
} else {
hexDump += hexChars.replace(/\s+$/, '');
}
}
return hexDump;
},
inspect: function (obj, depth, output) {
var codeStr = this.name + '([';
for (var i = 0 ; i < Math.min(this.hexDumpWidth, obj.length) ; i += 1) {
if (i > 0) {
codeStr += ', ';
}
var octet = obj[i];
codeStr += '0x' + leftPad(octet.toString(16).toUpperCase(), this.digitWidth, '0');
}
if (obj.length > this.hexDumpWidth) {
codeStr += ' /* ' + (obj.length - this.hexDumpWidth) + ' more */ ';
}
codeStr += '])';
output.code(codeStr, 'javascript');
},
diffLimit: 512,
diff: function (actual, expected, output, diff, inspect) {
var result = {diff: output};
if (Math.max(actual.length, expected.length) > this.diffLimit) {
result.diff.jsComment('Diff suppressed due to size > ' + this.diffLimit);
} else {
result.diff = utils.diffStrings(this.hexDump(actual), this.hexDump(expected), output, {type: 'Chars', markUpSpecialCharacters: false})
.replaceText(/[\x00-\x1f\x7f-\xff␊␍]/g, '.').replaceText(/[│ ]/g, function (styles, content) {
this.text(content);
});
}
return result;
}
});
if (typeof Buffer !== 'undefined') {
expect.addType({
name: 'Buffer',
base: 'binaryArray',
identify: Buffer.isBuffer
});
}
[8, 16, 32].forEach(function (numBits) {
['Int', 'Uint'].forEach(function (intOrUint) {
var constructorName = intOrUint + numBits + 'Array',
Constructor = this[constructorName];
if (typeof Constructor !== 'undefined') {
expect.addType({
name: constructorName,
base: 'binaryArray',
hexDumpWidth: 128 / numBits,
digitWidth: numBits / 4,
identify: function (obj) {
return obj instanceof Constructor;
}
});
}
}, this);
}, this);
expect.addType({
name: 'string',
identify: function (value) {
return typeof value === 'string';
},
inspect: function (value, depth, output) {
output.jsString('\'')
.jsString(JSON.stringify(value).replace(/^"|"$/g, '')
.replace(/'/g, "\\'")
.replace(/\\"/g, '"'))
.jsString('\'');
},
diff: function (actual, expected, output, diff, inspect) {
var result = {
diff: output,
inline: false
};
utils.diffStrings(actual, expected, output, {type: 'WordsWithSpace', markUpSpecialCharacters: true});
return result;
}
});
expect.addType({
name: 'number',
identify: function (value) {
return typeof value === 'number';
},
inspect: function (value, depth, output) {
if (value === 0 && 1 / value === -Infinity) {
value = '-0';
} else {
value = String(value);
}
output.jsNumber(String(value));
}
});
expect.addType({
name: 'NaN',
identify: function (value) {
return typeof value === 'number' && isNaN(value);
},
inspect: function (value, depth, output) {
output.jsPrimitive(value);
}
});
expect.addType({
name: 'boolean',
identify: function (value) {
return typeof value === 'boolean';
},
inspect: function (value, depth, output) {
output.jsPrimitive(value);
}
});
expect.addType({
name: 'undefined',
identify: function (value) {
return typeof value === 'undefined';
},
inspect: function (value, depth, output) {
output.jsPrimitive(value);
}
});
expect.addType({
name: 'null',
identify: function (value) {
return value === null;
},
inspect: function (value, depth, output) {
output.jsPrimitive(value);
}
});
};