Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove proxy frames from stack traces and improve docs/tests #884

Merged
merged 8 commits into from Jan 3, 2017
27 changes: 25 additions & 2 deletions lib/chai/assertion.js
Expand Up @@ -26,11 +26,34 @@ module.exports = function (_chai, util) {
*
* Creates object for chaining.
*
* `Assertion` objects contain metadata in the form of flags. Three flags can
* be assigned during instantiation by passing arguments to this constructor:
*
* - `object`: This flag contains the target of the assertion. For example, in
* the assertion `expect(numKittens).to.equal(7);`, the `object` flag will
* contain `numKittens` so that the `equal` assertion can reference it when
* needed.
*
* - `message`: This flag contains an optional custom error message to be
* prepended to the error message that's generated by the assertion when it
* fails.
*
* - `ssfi`: This flag stands for "start stack function indicator". It
* contains a function reference that serves as the starting point for
* removing frames from the stack trace of the error that's created by the
* assertion when it fails. The goal is to provide a cleaner stack trace to
* end users by removing Chai's internal functions. Note that it only works
* in environments that support `Error.captureStackTrace`, and only when
* `Chai.config.includeStack` hasn't been set to `false`.
*
* @param {Mixed} obj target of the assertion
* @param {String} msg (optional) custom error message
* @param {Function} stack (optional) starting point for removing stack frames
* @api private
*/

function Assertion (obj, msg, stack) {
flag(this, 'ssfi', stack || Assertion);
function Assertion (obj, msg, ssfi) {
flag(this, 'ssfi', ssfi || Assertion);
flag(this, 'object', obj);
flag(this, 'message', msg);

Expand Down
27 changes: 16 additions & 11 deletions lib/chai/utils/addChainableMethod.js
Expand Up @@ -59,7 +59,7 @@ var call = Function.prototype.call,
* @api public
*/

module.exports = function (ctx, name, method, chainingBehavior) {
module.exports = function addChainableMethod(ctx, name, method, chainingBehavior) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a big fan of naming every function even if they're being assigned to a variable.
Bonus style points for this excellent practice.

if (typeof chainingBehavior !== 'function') {
chainingBehavior = function () { };
}
Expand All @@ -76,13 +76,18 @@ module.exports = function (ctx, name, method, chainingBehavior) {
ctx.__methods[name] = chainableBehavior;

Object.defineProperty(ctx, name,
{ get: function () {
{ get: function chainableMethodGetter() {
chainableBehavior.chainingBehavior.call(this);

var assert = function assert() {
var old_ssfi = flag(this, 'ssfi');
if (old_ssfi)
flag(this, 'ssfi', assert);
var chainableMethodWrapper = function () {
// Use this chainable method wrapper as the starting point for
// removing implementation frames from the stack trace of a failed
// assertion. Note that this is the correct starting point even if
// this assertion has been overwritten since overwriting a chainable
// method merely replaces the saved methods in `ctx.__methods` instead
// of completely replacing the overwritten assertion.
flag(this, 'ssfi', chainableMethodWrapper);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just out of curiosity, I've always wondered what does ssfi means, I'm not sure this is a good name since it's kind of "cryptic" (or maybe it just feels like it because I don't know what it means).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this comment, it's "start stack function indicator". Also mentioned on http://chaijs.com/guide/plugins/. I don't feel strongly one way or another about renaming it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, since it's pretty specific I think there's nothing we can do about it.
Let's leave it that way.

Maybe a comment indicating what it means somewhere would also be nice, especially if we added a link to that comment, which was really useful for me when understanding how the whole stack trace manipulation works.


var result = chainableBehavior.method.apply(this, arguments);

if (result !== undefined) {
Expand All @@ -94,12 +99,12 @@ module.exports = function (ctx, name, method, chainingBehavior) {
return newAssertion;
};

addLengthGuard(assert, name, true);
addLengthGuard(chainableMethodWrapper, name, true);

// Use `__proto__` if available
if (hasProtoSupport) {
// Inherit all properties from the object by replacing the `Function` prototype
var prototype = assert.__proto__ = Object.create(this);
var prototype = chainableMethodWrapper.__proto__ = Object.create(this);
// Restore the `call` and `apply` methods from `Function`
prototype.call = call;
prototype.apply = apply;
Expand All @@ -110,13 +115,13 @@ module.exports = function (ctx, name, method, chainingBehavior) {
asserterNames.forEach(function (asserterName) {
if (!excludeNames.test(asserterName)) {
var pd = Object.getOwnPropertyDescriptor(ctx, asserterName);
Object.defineProperty(assert, asserterName, pd);
Object.defineProperty(chainableMethodWrapper, asserterName, pd);
}
});
}

transferFlags(this, assert);
return proxify(assert);
transferFlags(this, chainableMethodWrapper);
return proxify(chainableMethodWrapper);
}
, configurable: true
});
Expand Down
23 changes: 15 additions & 8 deletions lib/chai/utils/addMethod.js
Expand Up @@ -36,12 +36,19 @@ var transferFlags = require('./transferFlags');
* @api public
*/

module.exports = function (ctx, name, method) {
var fn = function () {
var keep_ssfi = flag(this, 'keep_ssfi');
var old_ssfi = flag(this, 'ssfi');
if (!keep_ssfi && old_ssfi)
flag(this, 'ssfi', fn);
module.exports = function addMethod(ctx, name, method) {
var methodWrapper = function () {
// If this assertion hasn't been overwritten, then use this method wrapper
// as the starting point for removing implementation frames from the stack
// trace of a failed assertion.
//
// Note: If this assertion has been overwritten, and thus the `keep_ssfi`
// flag is set, then the overwriting method wrapper is used as the starting
// point instead. This prevents the overwriting method wrapper from showing
// up in the stack trace since it's invoked before this method wrapper.
if (!flag(this, 'keep_ssfi')) {
flag(this, 'ssfi', methodWrapper);
}

var result = method.apply(this, arguments);
if (result !== undefined)
Expand All @@ -52,6 +59,6 @@ module.exports = function (ctx, name, method) {
return newAssertion;
};

addLengthGuard(fn, name, false);
ctx[name] = proxify(fn, name);
addLengthGuard(methodWrapper, name, false);
ctx[name] = proxify(methodWrapper, name);
};
29 changes: 23 additions & 6 deletions lib/chai/utils/addProperty.js
Expand Up @@ -6,6 +6,7 @@

var chai = require('../../chai');
var flag = require('./flag');
var isProxyEnabled = require('./isProxyEnabled');
var transferFlags = require('./transferFlags');

/**
Expand Down Expand Up @@ -34,15 +35,31 @@ var transferFlags = require('./transferFlags');
* @api public
*/

module.exports = function (ctx, name, getter) {
module.exports = function addProperty(ctx, name, getter) {
getter = getter === undefined ? new Function() : getter;

Object.defineProperty(ctx, name,
{ get: function addProperty() {
var keep_ssfi = flag(this, 'keep_ssfi');
var old_ssfi = flag(this, 'ssfi');
if (!keep_ssfi && old_ssfi)
flag(this, 'ssfi', addProperty);
{ get: function propertyGetter() {
// If proxy protection is disabled and this assertion hasn't been
// overwritten, then use this property getter as the starting point for
// removing implementation frames from the stack trace of a failed
// assertion.
//
// Notes:
//
// - If proxy protection is enabled, then the proxy getter is used as
// the starting point instead. This prevents the proxy getter from
// showing up in the stack trace since it's invoked before this
// property getter.
//
// - If proxy protection is disabled but this assertion has been
// overwritten, and thus the `keep_ssfi` flag is set, then the
// overwriting property getter is used as the starting point instead.
// This prevents the overwriting property getter from showing up in
// the stack trace since it's invoked before this property getter.
if (!isProxyEnabled() && !flag(this, 'keep_ssfi')) {
flag(this, 'ssfi', propertyGetter);
}

var result = getter.call(this);
if (result !== undefined)
Expand Down
2 changes: 1 addition & 1 deletion lib/chai/utils/compareByInspect.js
Expand Up @@ -26,6 +26,6 @@ var inspect = require('./inspect');
* @api public
*/

module.exports = function (a, b) {
module.exports = function compareByInspect(a, b) {
return inspect(a) < inspect(b) ? -1 : 1;
};
2 changes: 1 addition & 1 deletion lib/chai/utils/expectTypes.js
Expand Up @@ -22,7 +22,7 @@ var AssertionError = require('assertion-error');
var flag = require('./flag');
var type = require('type-detect');

module.exports = function (obj, types) {
module.exports = function expectTypes(obj, types) {
obj = flag(obj, 'object');
types = types.map(function (t) { return t.toLowerCase(); });
types.sort();
Expand Down
2 changes: 1 addition & 1 deletion lib/chai/utils/flag.js
Expand Up @@ -23,7 +23,7 @@
* @api private
*/

module.exports = function (obj, key, value) {
module.exports = function flag(obj, key, value) {
var flags = obj.__flags || (obj.__flags = Object.create(null));
if (arguments.length === 3) {
flags[key] = value;
Expand Down
2 changes: 1 addition & 1 deletion lib/chai/utils/getActual.js
Expand Up @@ -15,6 +15,6 @@
* @name getActual
*/

module.exports = function (obj, args) {
module.exports = function getActual(obj, args) {
return args.length > 4 ? args[4] : obj._obj;
};
2 changes: 1 addition & 1 deletion lib/chai/utils/getMessage.js
Expand Up @@ -32,7 +32,7 @@ var flag = require('./flag')
* @api public
*/

module.exports = function (obj, args) {
module.exports = function getMessage(obj, args) {
var negate = flag(obj, 'negate')
, val = flag(obj, 'object')
, expected = args[3]
Expand Down
8 changes: 7 additions & 1 deletion lib/chai/utils/index.js
Expand Up @@ -154,11 +154,17 @@ exports.checkError = require('check-error');
exports.proxify = require('./proxify');

/*!
* Proxify util
* addLengthGuard util
*/

exports.addLengthGuard = require('./addLengthGuard');

/*!
* isProxyEnabled helper
*/

exports.isProxyEnabled = require('./isProxyEnabled');

/*!
* isNaN method
*/
Expand Down
24 changes: 24 additions & 0 deletions lib/chai/utils/isProxyEnabled.js
@@ -0,0 +1,24 @@
var config = require('../config');

/*!
* Chai - isProxyEnabled helper
* Copyright(c) 2012-2014 Jake Luer <jake@alogicalparadox.com>
* MIT Licensed
*/

/**
* # isProxyEnabled()
*
* Helper function to check if Chai's proxy protection feature is enabled. If
* proxies are unsupported or disabled via the user's Chai config, then return
* false. Otherwise, return true.
*
* @namespace Utils
* @name isProxyEnabled
*/

module.exports = function isProxyEnabled() {
return config.useProxy &&
typeof Proxy !== 'undefined' &&
typeof Reflect !== 'undefined';
};
2 changes: 1 addition & 1 deletion lib/chai/utils/objDisplay.js
Expand Up @@ -24,7 +24,7 @@ var config = require('../config');
* @api public
*/

module.exports = function (obj) {
module.exports = function objDisplay(obj) {
var str = inspect(obj)
, type = Object.prototype.toString.call(obj);

Expand Down
6 changes: 3 additions & 3 deletions lib/chai/utils/overwriteChainableMethod.js
Expand Up @@ -40,11 +40,11 @@ var transferFlags = require('./transferFlags');
* @api public
*/

module.exports = function (ctx, name, method, chainingBehavior) {
module.exports = function overwriteChainableMethod(ctx, name, method, chainingBehavior) {
var chainableBehavior = ctx.__methods[name];

var _chainingBehavior = chainableBehavior.chainingBehavior;
chainableBehavior.chainingBehavior = function () {
chainableBehavior.chainingBehavior = function overwritingChainableMethodGetter() {
var result = chainingBehavior(_chainingBehavior).call(this);
if (result !== undefined) {
return result;
Expand All @@ -56,7 +56,7 @@ module.exports = function (ctx, name, method, chainingBehavior) {
};

var _method = chainableBehavior.method;
chainableBehavior.method = function () {
chainableBehavior.method = function overwritingChainableMethodWrapper() {
var result = method(_method).apply(this, arguments);
if (result !== undefined) {
return result;
Expand Down
28 changes: 20 additions & 8 deletions lib/chai/utils/overwriteMethod.js
Expand Up @@ -44,7 +44,7 @@ var transferFlags = require('./transferFlags');
* @api public
*/

module.exports = function (ctx, name, method) {
module.exports = function overwriteMethod(ctx, name, method) {
var _method = ctx[name]
, _super = function () {
throw new Error(name + ' is not a function');
Expand All @@ -53,12 +53,24 @@ module.exports = function (ctx, name, method) {
if (_method && 'function' === typeof _method)
_super = _method;

var fn = function () {
var keep_ssfi = flag(this, 'keep_ssfi');
var old_ssfi = flag(this, 'ssfi');
if (!keep_ssfi && old_ssfi)
flag(this, 'ssfi', fn);
var overwritingMethodWrapper = function () {
// If proxy protection is disabled and this overwriting assertion hasn't
// been overwritten again by yet another assertion, then use this method
// wrapper as the starting point for removing implementation frames from the
// stack trace of a failed assertion.
//
// Note: If this assertion has been overwritten, and thus the `keep_ssfi`
// flag is set, then the overwriting method wrapper is used as the starting
// point instead. This prevents the overwriting method wrapper from showing
// up in the stack trace since it's invoked before this method wrapper.
if (!flag(this, 'keep_ssfi')) {
flag(this, 'ssfi', overwritingMethodWrapper);
}

// The `keep_ssfi` flag is set so that if this assertion ends up calling
// the overwritten assertion, then the overwritten assertion doesn't attempt
// to use itself as the starting point for removing implementation frames
// from the stack trace of a failed assertion.
flag(this, 'keep_ssfi', true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me see if I've got this right, I'm not sure if I fully understand what this does.

So, we use the SSFI flag to store the function which should indicate where the real stack trace will start in order to remove internal implementation details, right?

Whenever an assertion gets overwritten we must turn the keep_ssfi flag to true before calling the old assertion (_super) in order to avoid that assertion being used as the start of the stack trace. Right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, so the function stored in the ssfi flag serves as the second parameter for Error.captureStackTrace, meaning that the function itself, and all functions called after it (which are just the internals of Chai and plugins), will be removed from the stack trace if an error is thrown. In the case of property assertions, this function is actually the proxy getter, which is why this PR was needed. But in the case of method assertions, the proxy getter function completely returns before the method assertion's function is invoked, so the method assertion's function needs to be stored in the ssfi flag instead of the proxy getter.

But what about all functions that came before the function stored in the ssfi flag? Usually, the first function that comes before it is the function that the user passed as the second argument to a Mocha it invocation. This function contains the user's assertion that failed, so it's good that it's included in the stack trace. But there's a bunch of other functions still in the stack before this one: Mocha's internals. It turns out that these don't appear in the stack traces either because Mocha does its own manual filtering of the stack trace to get rid of their internals. It's important to remember this when troubleshooting an issue with Mocha.

(Note that because the assert interface acts as a wrapper around Chai assertions, there's currently an extra function call between the user's assertion and the one stored in the ssfi flag, so it shows in the stack trace. That's what #878 is about. It's fixable but will take some work on the assert interface.)

As for your final question, yes, the keep_ssfi flag is needed so that the overwriting function remains stored in the ssfi flag even after it proceeds to call the original function that it overwrote. The first function after the user's assertion is the one that needs to be in the ssfi flag in order for this to all work correctly.

var result = method(_super).apply(this, arguments);
flag(this, 'keep_ssfi', false);
Expand All @@ -72,6 +84,6 @@ module.exports = function (ctx, name, method) {
return newAssertion;
}

addLengthGuard(fn, name, false);
ctx[name] = proxify(fn, name);
addLengthGuard(overwritingMethodWrapper, name, false);
ctx[name] = proxify(overwritingMethodWrapper, name);
};