"use strict"; /* eslint-disable no-invalid-this */ let checkError = require("check-error"); module.exports = (chai, utils) => { const Assertion = chai.Assertion; const assert = chai.assert; const proxify = utils.proxify; // If we are using a version of Chai that has checkError on it, // we want to use that version to be consistent. Otherwise, we use // what was passed to the factory. if (utils.checkError) { checkError = utils.checkError; } function isLegacyJQueryPromise(thenable) { // jQuery promises are Promises/A+-compatible since 3.0.0. jQuery 3.0.0 is also the first version // to define the catch method. return typeof thenable.catch !== "function" && typeof thenable.always === "function" && typeof thenable.done === "function" && typeof thenable.fail === "function" && typeof thenable.pipe === "function" && typeof thenable.progress === "function" && typeof thenable.state === "function"; } function assertIsAboutPromise(assertion) { if (typeof assertion._obj.then !== "function") { throw new TypeError(utils.inspect(assertion._obj) + " is not a thenable."); } if (isLegacyJQueryPromise(assertion._obj)) { throw new TypeError("Chai as Promised is incompatible with thenables of jQuery<3.0.0, sorry! Please " + "upgrade jQuery or use another Promises/A+ compatible library (see " + "http://promisesaplus.com/)."); } } function proxifyIfSupported(assertion) { return proxify === undefined ? assertion : proxify(assertion); } function method(name, asserter) { utils.addMethod(Assertion.prototype, name, function () { assertIsAboutPromise(this); return asserter.apply(this, arguments); }); } function property(name, asserter) { utils.addProperty(Assertion.prototype, name, function () { assertIsAboutPromise(this); return proxifyIfSupported(asserter.apply(this, arguments)); }); } function doNotify(promise, done) { promise.then(() => done(), done); } // These are for clarity and to bypass Chai refusing to allow `undefined` as actual when used with `assert`. function assertIfNegated(assertion, message, extra) { assertion.assert(true, null, message, extra.expected, extra.actual); } function assertIfNotNegated(assertion, message, extra) { assertion.assert(false, message, null, extra.expected, extra.actual); } function getBasePromise(assertion) { // We need to chain subsequent asserters on top of ones in the chain already (consider // `eventually.have.property("foo").that.equals("bar")`), only running them after the existing ones pass. // So the first base-promise is `assertion._obj`, but after that we use the assertions themselves, i.e. // previously derived promises, to chain off of. return typeof assertion.then === "function" ? assertion : assertion._obj; } function getReasonName(reason) { return reason instanceof Error ? reason.toString() : checkError.getConstructorName(reason); } // Grab these first, before we modify `Assertion.prototype`. const propertyNames = Object.getOwnPropertyNames(Assertion.prototype); const propertyDescs = {}; for (const name of propertyNames) { propertyDescs[name] = Object.getOwnPropertyDescriptor(Assertion.prototype, name); } property("fulfilled", function () { const derivedPromise = getBasePromise(this).then( value => { assertIfNegated(this, "expected promise not to be fulfilled but it was fulfilled with #{act}", { actual: value }); return value; }, reason => { assertIfNotNegated(this, "expected promise to be fulfilled but it was rejected with #{act}", { actual: getReasonName(reason) }); return reason; } ); module.exports.transferPromiseness(this, derivedPromise); return this; }); property("rejected", function () { const derivedPromise = getBasePromise(this).then( value => { assertIfNotNegated(this, "expected promise to be rejected but it was fulfilled with #{act}", { actual: value }); return value; }, reason => { assertIfNegated(this, "expected promise not to be rejected but it was rejected with #{act}", { actual: getReasonName(reason) }); // Return the reason, transforming this into a fulfillment, to allow further assertions, e.g. // `promise.should.be.rejected.and.eventually.equal("reason")`. return reason; } ); module.exports.transferPromiseness(this, derivedPromise); return this; }); method("rejectedWith", function (errorLike, errMsgMatcher, message) { let errorLikeName = null; const negate = utils.flag(this, "negate") || false; // rejectedWith with that is called without arguments is // the same as a plain ".rejected" use. if (errorLike === undefined && errMsgMatcher === undefined && message === undefined) { /* eslint-disable no-unused-expressions */ return this.rejected; /* eslint-enable no-unused-expressions */ } if (message !== undefined) { utils.flag(this, "message", message); } if (errorLike instanceof RegExp || typeof errorLike === "string") { errMsgMatcher = errorLike; errorLike = null; } else if (errorLike && errorLike instanceof Error) { errorLikeName = errorLike.toString(); } else if (typeof errorLike === "function") { errorLikeName = checkError.getConstructorName(errorLike); } else { errorLike = null; } const everyArgIsDefined = Boolean(errorLike && errMsgMatcher); let matcherRelation = "including"; if (errMsgMatcher instanceof RegExp) { matcherRelation = "matching"; } const derivedPromise = getBasePromise(this).then( value => { let assertionMessage = null; let expected = null; if (errorLike) { assertionMessage = "expected promise to be rejected with #{exp} but it was fulfilled with #{act}"; expected = errorLikeName; } else if (errMsgMatcher) { assertionMessage = `expected promise to be rejected with an error ${matcherRelation} #{exp} but ` + `it was fulfilled with #{act}`; expected = errMsgMatcher; } assertIfNotNegated(this, assertionMessage, { expected, actual: value }); return value; }, reason => { const errorLikeCompatible = errorLike && (errorLike instanceof Error ? checkError.compatibleInstance(reason, errorLike) : checkError.compatibleConstructor(reason, errorLike)); const errMsgMatcherCompatible = errMsgMatcher && checkError.compatibleMessage(reason, errMsgMatcher); const reasonName = getReasonName(reason); if (negate && everyArgIsDefined) { if (errorLikeCompatible && errMsgMatcherCompatible) { this.assert(true, null, "expected promise not to be rejected with #{exp} but it was rejected " + "with #{act}", errorLikeName, reasonName); } } else { if (errorLike) { this.assert(errorLikeCompatible, "expected promise to be rejected with #{exp} but it was rejected with #{act}", "expected promise not to be rejected with #{exp} but it was rejected " + "with #{act}", errorLikeName, reasonName); } if (errMsgMatcher) { this.assert(errMsgMatcherCompatible, `expected promise to be rejected with an error ${matcherRelation} #{exp} but got ` + `#{act}`, `expected promise not to be rejected with an error ${matcherRelation} #{exp}`, errMsgMatcher, checkError.getMessage(reason)); } } return reason; } ); module.exports.transferPromiseness(this, derivedPromise); return this; }); property("eventually", function () { utils.flag(this, "eventually", true); return this; }); method("notify", function (done) { doNotify(getBasePromise(this), done); return this; }); method("become", function (value, message) { return this.eventually.deep.equal(value, message); }); // ### `eventually` // We need to be careful not to trigger any getters, thus `Object.getOwnPropertyDescriptor` usage. const methodNames = propertyNames.filter(name => { return name !== "assert" && typeof propertyDescs[name].value === "function"; }); methodNames.forEach(methodName => { Assertion.overwriteMethod(methodName, originalMethod => function () { return doAsserterAsyncAndAddThen(originalMethod, this, arguments); }); }); const getterNames = propertyNames.filter(name => { return name !== "_obj" && typeof propertyDescs[name].get === "function"; }); getterNames.forEach(getterName => { // Chainable methods are things like `an`, which can work both for `.should.be.an.instanceOf` and as // `should.be.an("object")`. We need to handle those specially. const isChainableMethod = Assertion.prototype.__methods.hasOwnProperty(getterName); if (isChainableMethod) { Assertion.overwriteChainableMethod( getterName, originalMethod => function () { return doAsserterAsyncAndAddThen(originalMethod, this, arguments); }, originalGetter => function () { return doAsserterAsyncAndAddThen(originalGetter, this); } ); } else { Assertion.overwriteProperty(getterName, originalGetter => function () { return proxifyIfSupported(doAsserterAsyncAndAddThen(originalGetter, this)); }); } }); function doAsserterAsyncAndAddThen(asserter, assertion, args) { // Since we're intercepting all methods/properties, we need to just pass through if they don't want // `eventually`, or if we've already fulfilled the promise (see below). if (!utils.flag(assertion, "eventually")) { asserter.apply(assertion, args); return assertion; } const derivedPromise = getBasePromise(assertion).then(value => { // Set up the environment for the asserter to actually run: `_obj` should be the fulfillment value, and // now that we have the value, we're no longer in "eventually" mode, so we won't run any of this code, // just the base Chai code that we get to via the short-circuit above. assertion._obj = value; utils.flag(assertion, "eventually", false); return args ? module.exports.transformAsserterArgs(args) : args; }).then(newArgs => { asserter.apply(assertion, newArgs); // Because asserters, for example `property`, can change the value of `_obj` (i.e. change the "object" // flag), we need to communicate this value change to subsequent chained asserters. Since we build a // promise chain paralleling the asserter chain, we can use it to communicate such changes. return assertion._obj; }); module.exports.transferPromiseness(assertion, derivedPromise); return assertion; } // ### Now use the `Assertion` framework to build an `assert` interface. const originalAssertMethods = Object.getOwnPropertyNames(assert).filter(propName => { return typeof assert[propName] === "function"; }); assert.isFulfilled = (promise, message) => (new Assertion(promise, message)).to.be.fulfilled; assert.isRejected = (promise, errorLike, errMsgMatcher, message) => { const assertion = new Assertion(promise, message); return assertion.to.be.rejectedWith(errorLike, errMsgMatcher, message); }; assert.becomes = (promise, value, message) => assert.eventually.deepEqual(promise, value, message); assert.doesNotBecome = (promise, value, message) => assert.eventually.notDeepEqual(promise, value, message); assert.eventually = {}; originalAssertMethods.forEach(assertMethodName => { assert.eventually[assertMethodName] = function (promise) { const otherArgs = Array.prototype.slice.call(arguments, 1); let customRejectionHandler; const message = arguments[assert[assertMethodName].length - 1]; if (typeof message === "string") { customRejectionHandler = reason => { throw new chai.AssertionError(`${message}\n\nOriginal reason: ${utils.inspect(reason)}`); }; } const returnedPromise = promise.then( fulfillmentValue => assert[assertMethodName].apply(assert, [fulfillmentValue].concat(otherArgs)), customRejectionHandler ); returnedPromise.notify = done => { doNotify(returnedPromise, done); }; return returnedPromise; }; }); }; module.exports.transferPromiseness = (assertion, promise) => { assertion.then = promise.then.bind(promise); }; module.exports.transformAsserterArgs = values => values;