diff --git a/.travis.yml b/.travis.yml index b41ba14..39f3a50 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,7 @@ node_js: - "5" - "6" - "7" + - "8" notifications: email: ["andri@dot.ee"] diff --git a/must.d.ts b/must.d.ts index 2f2c2ec..561cd5b 100644 --- a/must.d.ts +++ b/must.d.ts @@ -8,6 +8,8 @@ interface Must { be: CallableMust; before(expected): Must; below(expected): Must; + betray(catchCondition?: (reason: any) => TResult | PromiseLike): Promise; + betray(catchCondition?: (reason: any) => void): Promise; between(begin, end): Must; boolean(): Must; contain(expected): Must; @@ -24,6 +26,8 @@ interface Must { false(): Must; falsy(): Must; frozen(): Must; + fulfill(fulfilledCondition?: (value?: any) => TResult | PromiseLike): Promise; + fulfill(fulfilledCondition?: (value?: any) => void): Promise; function(): Must; gt(expected: number): Must; gte(expected: number): Must; @@ -52,6 +56,7 @@ interface Must { ownProperties(properties: any): Must; ownProperty(property: string, value?): Must; permutationOf(expected: Array): Must; + promise(): Must; properties(properties: any): Must; property(property: string, value?): Must; regexp(): Must; @@ -93,4 +98,41 @@ declare global { interface Array { must: Must; } + + // copied from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/es6-shim/index.d.ts + interface PromiseLike { + /** + * Attaches callbacks for the resolution and/or rejection of the Promise. + * @param onfulfilled The callback to execute when the Promise is resolved. + * @param onrejected The callback to execute when the Promise is rejected. + * @returns A Promise for the completion of which ever callback is executed. + */ + then(onfulfilled?: (value: T) => TResult | PromiseLike, onrejected?: (reason: any) => TResult | PromiseLike): PromiseLike; + + then(onfulfilled?: (value: T) => TResult | PromiseLike, onrejected?: (reason: any) => void): PromiseLike; + } + + /** + * Represents the completion of an asynchronous operation + */ + interface Promise { + /** + * Attaches callbacks for the resolution and/or rejection of the Promise. + * @param onfulfilled The callback to execute when the Promise is resolved. + * @param onrejected The callback to execute when the Promise is rejected. + * @returns A Promise for the completion of which ever callback is executed. + */ + then(onfulfilled?: (value: T) => TResult | PromiseLike, onrejected?: (reason: any) => TResult | PromiseLike): Promise; + + then(onfulfilled?: (value: T) => TResult | PromiseLike, onrejected?: (reason: any) => void): Promise; + + /** + * Attaches a callback for only the rejection of the Promise. + * @param onrejected The callback to execute when the Promise is rejected. + * @returns A Promise for the completion of the callback. + */ + catch(onrejected?: (reason: any) => T | PromiseLike): Promise; + + catch(onrejected?: (reason: any) => void): Promise; + } } diff --git a/must.js b/must.js index b61ae37..e8eac8f 100644 --- a/must.js +++ b/must.js @@ -1183,6 +1183,149 @@ defineGetter(Must.prototype, "reject", function() { return Rejectable(this) }) +/** + * Assert that an object is a promise, and returns it. + * + * The determination uses duck typing, i.e., it checks whether the object has + * a `then` and a `catch` method. + * + * There are several implementations of promises in the wild, and a promise you + * receive from a library might not be an `instanceof` _your_ `Promise` type, + * although they can work together. + * + * ```javascript + * promise.must.be.a(Promise) + * ``` + * + * might fail, while + * + * ```javascript + * promise.must.be.a.promise + * ``` + * + * might be upheld. + * + * @example + * Promise.resolve(42).must.be.a.promise() + * Promise.reject(42).must.be.a.promise() + * + * @method promise + */ +Must.prototype.promise = function() { + this.assert(isPromise(this.actual), isPromiseMsg, { actual: this.actual }) + return this.actual +} + +/** + * Assert that an object is a promise (see `promise`), that eventually resolves. + * The assertion returns a promise that settles to the outcome (resolve result or + * reject error) of `fulfilledCondition`. `fulfilledCondition` is called with the + * resolution (resolve result) of the original promise when it resolves. + * If the original promise is rejected, this assertion fails, and `fulfilledCondition` + * is not called. `fulfilledCondition` is optional. + * + * This approach makes it possible to immediate express assertions about the original + * promise's resolve result. + * + * You should not use `not` to negate `fulfill`. Things will get weird. Use `betray` + * to express that the promise should be rejected instead. + * + * @example + * Promise.resolve(42).must.fulfill() + * Promise.resolve(42).must.fulfill(function(result) { + * result.must.be.a.number() + * result.must.be.truthy() + * return result // the resulting promise will be fulfilled + * }) + * Promise.resolve(42).must.fulfill(function(result) { + * result.must.be.a.number() + * result.must.be.truthy() + * throw result // the resulting promise will be rejected + * }) + * Promise.resolve(42).must.fulfill(function(result) { + * result.must.not.be.a.number() // fails + * result.must.be.truthy() + * return result + * }) + * Promise.reject(new Error()).must.fulfill(function(result) { // fulfill fails, callback is not executed + * result.must.not.be.a.number() + * result.must.be.truthy() + * return result + * }) + * + * @method fulfill + * @param fulfilledCondition + */ +Must.prototype.fulfill = function(fulfilledCondition) { + var must = this + must.assert(isPromise(this.actual), isPromiseMsg, {actual: this.actual}) + var caught = must.actual.catch(function(err) { + must.assert( + false, + "resolve, but got rejected with \'" + (err && err.message ? err.message : err) + "\'", + {actual: must.actual} + ) + }) + return fulfilledCondition ? caught.then(fulfilledCondition) : caught +} + +/** + * Assert that an object is a promise (see `promise`), that eventually rejects ("betrays" the promise). + * The assertion returns a promise that settles to the outcome (resolve result or + * reject error) of `catchCondition`. `catchCondition` is called with the + * error (reject result) of the original promise when it rejects. + * If the original promise is fulfilled (resolved), this assertion fails, and `catchCondition` + * is not called. `catchCondition` is optional. + * + * This approach makes it possible to immediate express assertions about the original + * promise's reject error. + * + * You should not use `not` to negate `betray`. Things will get weird. Use `fulfill` + * to express that the promise should be resolved instead. + * + * @example + * Promise.reject(new Error()).must.betray() + * Promise.reject(42).must.betray(function(err) { + * err.must.be.a.number() + * err.must.be.truthy() + * return result // the resulting promise will be fulfilled + * }) + * Promise.reject(42).must.betray(function(err) { + * err.must.be.a.number() + * err.must.be.truthy() + * throw result // the resulting promise will be rejected + * }) + * Promise.reject(42).must.betray(function(err) { + * err.must.not.be.a.number() // fails + * err.must.be.truthy() + * return result + * }) + * Promise.resolve(42).must.betray(function(err) { // betray fails, callback is not executed + * err.must.not.be.a.number() + * err.must.be.truthy() + * return result + * }) + * + * @method betray + * @param catchCondition + */ +Must.prototype.betray = function(catchCondition) { + var must = this + must.assert(isPromise(this.actual), isPromiseMsg, {actual: this.actual}) + return must.actual.then( + function(result) { + must.assert( + false, + "reject, but got fulfilled with \'" + stringify(result) + "\'", + {actual: must.actual} + ) + }, + catchCondition + ? catchCondition + : function(err) { throw err } + ) +} + /** * Assert a string starts with the given string. * @@ -1285,4 +1428,10 @@ function messageFromError(err) { function isFn(fn) { return typeof fn === "function" } function isNumber(n) { return typeof n === "number" || n instanceof Number } + +var isPromiseMsg = "be a promise (i.e., have a \'then\' and a \'catch\' function)" +function isPromise(p) { + return p && typeof p.then === "function" && typeof p.catch === "function" +} + function passthrough() { return this } diff --git a/test/must/_failing_promise_tests.js b/test/must/_failing_promise_tests.js new file mode 100644 index 0000000..53bd971 --- /dev/null +++ b/test/must/_failing_promise_tests.js @@ -0,0 +1,66 @@ +var Must = require("../..") +var assert = require("./assert") +var assertionErrorTest = require("./_assertion_error_test") + +function dummy () {} + +var thenNoCatch = {then: dummy} +var catchNoThen = {catch: dummy} +var catchAndThen = {then: dummy, catch: dummy} + +module.exports = function(callToTest) { + it("must fail given null", function () { + assert.fail(function () { callToTest(Must(null)) }) + }) + + it("must fail given undefined", function () { + assert.fail(function () { callToTest(Must(undefined)) }) + }) + + it("must fail given boolean primitive", function () { + assert.fail(function () { callToTest(Must(true)) }) + assert.fail(function () { callToTest(Must(false)) }) + }) + + it("must fail given number primitive", function () { + assert.fail(function () { callToTest(Must(42)) }) + }) + + it("must fail given string primitive", function () { + assert.fail(function () { callToTest(Must("")) }) + }) + + it("must fail given array", function () { + assert.fail(function () { callToTest(Must([])) }) + }) + + it("must fail given object", function () { + assert.fail(function () { callToTest(Must({})) }) + }) + + it("must fail given an object with a then function, but not a catch function", function () { + assert.fail(function () { callToTest(Must(thenNoCatch)) }) + }) + + it("must fail given an object with a catch function, but not a then function", function () { + assert.fail(function () { callToTest(Must(catchNoThen)) }) + }) + + assertionErrorTest( + function() { callToTest(Must(catchNoThen)) }, + { + actual: catchNoThen, + message: "{} must be a promise (i.e., have a \'then\' and a \'catch\' function)" + } + ) + + describe(".not", function() { + it("must invert the assertion", function() { + assert.fail(function() { callToTest(Must(catchAndThen).not) }) + }) + }) +} + +module.exports.thenNoCatch = thenNoCatch +module.exports.catchNoThen = catchNoThen +module.exports.catchAndThen = catchAndThen diff --git a/test/must/betray_test.js b/test/must/betray_test.js new file mode 100644 index 0000000..2a5cee2 --- /dev/null +++ b/test/must/betray_test.js @@ -0,0 +1,136 @@ +var Promise = global.Promise || require("promise") +var Must = require("../..") +var failingPromiseTests = require("./_failing_promise_tests") +var assert = require("./assert") +var stringify = require("../../lib").stringify + +describe("Must.prototype.betray", function() { + failingPromiseTests(function(must) { must.betray() }) + + it( + "must pass given a Promise that rejects, and eventually pass, and reject itself", + function(done) { + var rejection = new Error() + assert.pass( + function() { Must(Promise.reject(rejection)).betray().then(raise(done), assertStrictEqual(done, rejection)) } + ) + } + ) + + it("must pass given a Promise that resolves, and eventually fail", function(done) { + assert.pass(function() { Must(Promise.resolve(42)).betray().then(raise(done), assertThrown(done)) }) + }) + + it( + "must pass given a Promise that rejects and a catchCondition that returns, " + + "and eventually pass, and resolve to the result of the catchCondition", + function(done) { + var called = false + assert.pass(function() { + Must(Promise.reject(42)).betray(function(result) { + called = true + result.must.be.a.number() + result.must.be.truthy() + return result // the resulting promise will be fulfilled + }).then(assertStrictEqual(done, 42, function() { return called }), raise(done)) + }) + } + ) + + it( + "must pass given a Promise that rejects and a catchCondition that throws, " + + "and eventually pass, and reject to the rejection of the catchCondition", + function(done) { + var called = false + assert.pass(function() { + Must(Promise.reject(42)).betray(function(err) { + called = true + err.must.be.a.number() + err.must.be.truthy() + throw err // the resulting promise will be rejected + }) + .then(raise(done), assertStrictEqual(done, 42, function() { return called })) + }) + } + ) + + it( + "must pass given a Promise that rejects and a catchCondition that fails, and eventually fail", + function(done) { + var called = false + assert.pass(function() { + Must(Promise.reject(42)).betray(function(result) { + called = true + result.must.not.be.a.number() // fails + result.must.be.truthy() + return result + }) + .then(raise(done), assertThrown(done, function() { return called })) + }) + } + ) + + it( + "must pass given a Promise that resolves, and eventually fail, without calling the catchCondition", + function(done) { + var called = false + assert.pass(function() { + Must(Promise.resolve(42)).betray(function() { // betray fails, callback is not executed + called = true + }) + .then(raise(done), assertThrown(done, function() { return !called })) + }) + } + ) + + it("AssertionError must have all properties when it fails because of a resolution", function(done) { + var resolution = 42 + var subject = Promise.resolve(resolution) + Must(subject).betray().then( + raise(done), + function(err) { + try { + assert(err instanceof Must.AssertionError) + assert.deepEqual( + err, + { + actual: subject, + message: stringify(subject) + " must reject, but got fulfilled with \'" + stringify(resolution) + "\'" + } + ) + done() + } + catch (assertErr) { + done(assertErr) + } + } + ) + }) + +}) + +function assertStrictEqual(done, expected, called) { + return function(value) { + if (called) { + assert(called()) + } + assert.strictEqual(value, expected) + done() + } +} +function assertThrown(done, called) { + return function(err) { + if (called) { + assert(called()) + } + if (err instanceof Must.AssertionError) { + done() + } + else { + done(new Error("not a Must.AssertionError: " + err.message)) + } + } +} +function raise(done) { + return function() { done(new Error("Must fail")) } +} diff --git a/test/must/fulfill_test.js b/test/must/fulfill_test.js new file mode 100644 index 0000000..48ad7ef --- /dev/null +++ b/test/must/fulfill_test.js @@ -0,0 +1,133 @@ +var Promise = global.Promise || require("promise") +var Must = require("../..") +var failingPromiseTests = require("./_failing_promise_tests") +var assert = require("./assert") +var stringify = require("../../lib").stringify + +describe("Must.prototype.fulfill", function() { + failingPromiseTests(function(must) { must.fulfill() }) + + it("must pass given a Promise that rejects, and eventually fail", function(done) { + assert.pass(function() { Must(Promise.reject(new Error())).fulfill().then(raise(done),assertThrown(done)) }) + }) + + it( + "must pass given a Promise that resolves, and eventually pass, and resolve itself", + function(done) { + assert.pass(function() { Must(Promise.resolve(42)).fulfill().then(assertStrictEqual(done, 42), raise(done)) }) + } + ) + + it( + "must pass given a Promise that resolves and a fulfilledCondition that returns, " + + "and eventually pass, and resolve to the result of the fulfilledCondition", + function(done) { + var called = false + assert.pass(function() { + Must(Promise.resolve(42)).fulfill(function(result) { + called = true + result.must.be.a.number() + result.must.be.truthy() + return result // the resulting promise will be fulfilled + }) + .then(assertStrictEqual(done, 42, function() { return called }), raise(done)) + }) + } + ) + + it( + "must pass given a Promise that resolves and a fulfilledCondition that throws, " + + "and eventually pass, and reject to the rejection of the fulfilledCondition", + function(done) { + var called = false + assert.pass(function() { + Must(Promise.resolve(42)).fulfill(function(result) { + called = true + result.must.be.a.number() + result.must.be.truthy() + throw result // the resulting promise will be rejected + }) + .then(raise(done), assertStrictEqual(done, 42, function() { return called })) + }) + } + ) + + it( + "must pass given a Promise that resolves and a fulfilledCondition that fails, and eventually fail", + function(done) { + var called = false + assert.pass(function() { + Must(Promise.resolve(42)).fulfill(function(result) { + called = true + result.must.not.be.a.number() // fails + result.must.be.truthy() + return result + }) + .then(raise(done), assertThrown(done, function() { return called })) + }) + } + ) + + it( + "must pass given a Promise that rejects, and eventually fail, without calling the fulfilledCondition", + function(done) { + var called = false + assert.pass(function() { + Must(Promise.reject(new Error('rejection'))).fulfill(function() { // fulfill fails, callback is not executed + called = true + }) + .then(raise(done), assertThrown(done, function() { return !called })) + }) + } + ) + + it("AssertionError must have all properties when it fails because of a rejection", function(done) { + var message = "rejection message" + var subject = Promise.reject(new Error(message)) + Must(subject).fulfill().then( + raise(done), + function(err) { + try { + assert(err instanceof Must.AssertionError) + assert.deepEqual( + err, + { + actual: subject, + message: stringify(subject) + " must resolve, but got rejected with \'" + message + "\'" + } + ) + done() + } catch (assertErr) { + done(assertErr) + } + } + ) + }) + +}) + +function assertStrictEqual(done, expected, called) { + return function(value) { + if (called) { + assert(called()) + } + assert.strictEqual(value, expected) + done() + } +} +function assertThrown(done, called) { + return function(err) { + if (called) { + assert(called()) + } + if (err instanceof Must.AssertionError) { + done() + } + else { + done(new Error("not a Must.AssertionError: " + err.message)) + } + } +} +function raise(done) { + return function() { done(new Error("Must fail")) } +} diff --git a/test/must/promise_test.js b/test/must/promise_test.js new file mode 100644 index 0000000..625898b --- /dev/null +++ b/test/must/promise_test.js @@ -0,0 +1,38 @@ +var Promise = global.Promise || require("promise") +var Must = require("../..") +var failingPromiseTests = require("./_failing_promise_tests") +var assert = require("./assert") + +describe("Must.prototype.promise", function() { + failingPromiseTests(function(must) { must.promise() }) + + it("must pass given an object with a catch and a then function", function () { + assert.pass(function () { Must(failingPromiseTests.catchAndThen).be.promise() }) + }) + + it("must pass given a Promise implementation, with a resolved promise", function () { + assert.pass(function () { Must(Promise.resolve(42)).be.promise() }) + }) + + it("must pass given a Promise implementation, with a rejected promise (and passes it through)", function(done) { + var p = Promise.reject(new Error()) + assert.pass(function() { Must(p).be.promise() }) + p.catch(function() { done() }) // deal with UnhandledPromiseRejectionWarning + }) + + it("passes through a resolved promise", function() { + var p = Promise.resolve(42) + assert(Must(p).be.promise() === p) + }) + + it("passes through a rejected promise", function(done) { + var rejection = new Error() + var p = Promise.reject(rejection) + var outcome = Must(p).be.promise() + assert(outcome === p) + p.catch(function(err) { // deal with UnhandledPromiseRejectionWarning + assert(err === rejection) + done() + }) + }) +})