diff --git a/lib/chai/utils/proxify.js b/lib/chai/utils/proxify.js index 314421b7e..b08fe2638 100644 --- a/lib/chai/utils/proxify.js +++ b/lib/chai/utils/proxify.js @@ -1,4 +1,5 @@ var config = require('../config'); +var getProperties = require('./getProperties'); /*! * Chai - proxify utility @@ -30,10 +31,62 @@ module.exports = function proxify (obj) { // the `config.proxyExcludedKeys` setting. if (typeof property === 'string' && config.proxyExcludedKeys.indexOf(property) === -1 && - !Reflect.has(target, property)) - throw Error('Invalid Chai property: ' + property); + !Reflect.has(target, property)) { + var orderedProperties = getProperties(target).filter(function(property) { + return !Object.prototype.hasOwnProperty(property) && + ['__flags', '__methods', '_obj', 'assert'].indexOf(property) === -1; + }).sort(function(a, b) { + return stringDistance(property, a) - stringDistance(property, b); + }); + + if (orderedProperties.length && + stringDistance(orderedProperties[0], property) < 4) { + // If the property is reasonably close to an existing Chai property, + // suggest that property to the user. + throw Error('Invalid Chai property: ' + property + + '. Did you mean "' + orderedProperties[0] + '"?'); + } else { + throw Error('Invalid Chai property: ' + property); + } + } return target[property]; } }); }; + +/** + * # stringDistance(strA, strB) + * Return the Levenshtein distance between two strings. + * @param {string} strA + * @param {string} strB + * @return {number} the string distance between strA and strB + * @api private + */ + +function stringDistance(strA, strB, memo) { + if (!memo) { + // `memo` is a two-dimensional array containing a cache of distances + // memo[i][j] is the distance between strA.slice(0, i) and + // strB.slice(0, j). + memo = []; + for (var i = 0; i <= strA.length; i++) { + memo[i] = []; + } + } + + if (!memo[strA.length] || !memo[strA.length][strB.length]) { + if (strA.length === 0 || strB.length === 0) { + memo[strA.length][strB.length] = Math.max(strA.length, strB.length); + } else { + memo[strA.length][strB.length] = Math.min( + stringDistance(strA.slice(0, -1), strB, memo) + 1, + stringDistance(strA, strB.slice(0, -1), memo) + 1, + stringDistance(strA.slice(0, -1), strB.slice(0, -1), memo) + + (strA.slice(-1) === strB.slice(-1) ? 0 : 1) + ); + } + } + + return memo[strA.length][strB.length]; +} diff --git a/test/utilities.js b/test/utilities.js index 66d6a8178..77db79b01 100644 --- a/test/utilities.js +++ b/test/utilities.js @@ -870,6 +870,38 @@ describe('utilities', function () { }).to.throw('Invalid Chai property: mushrooms'); }); + it('suggests a fix if a non-existent prop looks like a typo', function () { + var pizza = proxify({foo: 1, bar: 2, baz: 3}); + + expect(function () { + pizza.phoo; + }).to.throw('Invalid Chai property: phoo. Did you mean "foo"?'); + }); + + it('doesn\'t take exponential time to find string distances', function () { + var pizza = proxify({veryLongPropertyNameWithLotsOfLetters: 1}); + + expect(function () { + pizza.extremelyLongPropertyNameWithManyLetters; + }).to.throw( + 'Invalid Chai property: extremelyLongPropertyNameWithManyLetters' + ); + }); + + it('doesn\'t suggest properties from Object.prototype', function () { + var pizza = proxify({string: 5}); + expect(function () { + pizza.tostring; + }).to.throw('Invalid Chai property: tostring. Did you mean "string"?'); + }); + + it('doesn\'t suggest internally properties', function () { + var pizza = proxify({flags: 5, __flags: 6}); + expect(function () { + pizza.___flags; // 3 underscores; closer to '__flags' than 'flags' + }).to.throw('Invalid Chai property: ___flags. Did you mean "flags"?'); + }); + // .then is excluded from property validation for promise support it('doesn\'t throw error if non-existent `then` is read', function () { var pizza = proxify({});