From dbc1a0aa56c574b8fe6ecbf032e15490497d5f07 Mon Sep 17 00:00:00 2001 From: Steve Gravrock Date: Wed, 23 Jun 2021 20:13:01 -0700 Subject: [PATCH] Added expectAsync(...).already * Causes async matchers to immediately fail if the promise is pending * Fixes #1845 --- lib/jasmine-core/jasmine.js | 57 ++++++++++++++++++ spec/core/baseSpec.js | 29 +++++++++ spec/core/integration/MatchersSpec.js | 85 +++++++++++++++++++++++++++ src/core/Expectation.js | 44 ++++++++++++++ src/core/base.js | 13 ++++ 5 files changed, 228 insertions(+) diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 41b88f0b..1ad0935a 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -371,6 +371,19 @@ getJasmineRequireObj().base = function(j$, jasmineGlobal) { return matches ? matches[1] : ''; }; + j$.isPending_ = function(promise) { + var sentinel = {}; + // eslint-disable-next-line compat/compat + return Promise.race([promise, Promise.resolve(sentinel)]).then( + function(result) { + return result === sentinel; + }, + function() { + return false; + } + ); + }; + /** * Get a matcher, usable in any {@link matchers|matcher} that uses Jasmine's equality (e.g. {@link matchers#toEqual|toEqual}, {@link matchers#toContain|toContain}, or {@link matchers#toHaveBeenCalledWith|toHaveBeenCalledWith}), * that will succeed if the actual value being compared is an instance of the specified class/constructor. @@ -3729,6 +3742,12 @@ getJasmineRequireObj().Expectation = function(j$) { * Asynchronous matchers that operate on an actual value which is a promise, * and return a promise. * + * Most async matchers will wait indefinitely for the promise to be resolved + * or rejected, resulting in a spec timeout if that never happens. If you + * expect that the promise will already be resolved or rejected at the time + * the matcher is called, you can use the {@link async-matchers#already} + * modifier to get a faster failure with a more helpful message. + * * Note: Specs must await the result of each async matcher, return the * promise returned by the matcher, or return a promise that's derived from * the one returned by the matcher. Otherwise the matcher will not be @@ -3798,6 +3817,23 @@ getJasmineRequireObj().Expectation = function(j$) { } }); + /** + * Fail as soon as possible if the actual is pending. + * Otherwise evaluate the matcher. + * @member + * @name async-matchers#already + * @type {async-matchers} + * @example + * await expectAsync(myPromise).already.toBeResolved(); + * @example + * return expectAsync(myPromise).already.toBeResolved(); + */ + Object.defineProperty(AsyncExpectation.prototype, 'already', { + get: function() { + return addFilter(this, expectSettledPromiseFilter); + } + }); + function wrapSyncCompare(name, matcherFactory) { return function() { var result = this.expector.compare(name, matcherFactory, arguments); @@ -3876,6 +3912,27 @@ getJasmineRequireObj().Expectation = function(j$) { buildFailureMessage: negatedFailureMessage }; + var expectSettledPromiseFilter = { + selectComparisonFunc: function(matcher) { + return function(actual) { + var matcherArgs = arguments; + + return j$.isPending_(actual).then(function(isPending) { + if (isPending) { + return { + pass: false, + message: + 'Expected a promise to be settled (via ' + + 'expectAsync(...).already) but it was pending.' + }; + } else { + return matcher.compare.apply(null, matcherArgs); + } + }); + }; + } + }; + function ContextAddingFilter(message) { this.message = message; } diff --git a/spec/core/baseSpec.js b/spec/core/baseSpec.js index 99a0e668..51b90063 100644 --- a/spec/core/baseSpec.js +++ b/spec/core/baseSpec.js @@ -75,4 +75,33 @@ describe('base helpers', function() { expect(jasmineUnderTest.isURL({})).toBe(false); }); }); + + describe('isPending_', function() { + it('returns a promise that resolves to true when the promise is pending', function() { + jasmine.getEnv().requirePromises(); + // eslint-disable-next-line compat/compat + var promise = new Promise(function() {}); + return expectAsync(jasmineUnderTest.isPending_(promise)).toBeResolvedTo( + true + ); + }); + + it('returns a promise that resolves to false when the promise is resolved', function() { + jasmine.getEnv().requirePromises(); + // eslint-disable-next-line compat/compat + var promise = Promise.resolve(); + return expectAsync(jasmineUnderTest.isPending_(promise)).toBeResolvedTo( + false + ); + }); + + it('returns a promise that resolves to false when the promise is rejected', function() { + jasmine.getEnv().requirePromises(); + // eslint-disable-next-line compat/compat + var promise = Promise.reject(); + return expectAsync(jasmineUnderTest.isPending_(promise)).toBeResolvedTo( + false + ); + }); + }); }); diff --git a/spec/core/integration/MatchersSpec.js b/spec/core/integration/MatchersSpec.js index ac9f5e90..86fe77c4 100644 --- a/spec/core/integration/MatchersSpec.js +++ b/spec/core/integration/MatchersSpec.js @@ -754,4 +754,89 @@ describe('Matchers (Integration)', function() { 'a predicate, but it threw Error with message |nope|.' }); }); + + describe('When an async matcher is used with .already()', function() { + it('propagates the matcher result when the promise is resolved', function(done) { + jasmine.getEnv().requirePromises(); + + env.it('a spec', function() { + // eslint-disable-next-line compat/compat + return env.expectAsync(Promise.resolve()).already.toBeRejected(); + }); + + var specExpectations = function(result) { + expect(result.status).toEqual('failed'); + expect(result.failedExpectations.length) + .withContext('Number of failed expectations') + .toEqual(1); + expect(result.failedExpectations[0].message).toEqual( + 'Expected [object Promise] to be rejected.' + ); + expect(result.failedExpectations[0].matcherName) + .withContext('Matcher name') + .not.toEqual(''); + }; + + env.addReporter({ specDone: specExpectations }); + env.execute(null, done); + }); + + it('propagates the matcher result when the promise is rejected', function(done) { + jasmine.getEnv().requirePromises(); + + env.it('a spec', function() { + return ( + env + // eslint-disable-next-line compat/compat + .expectAsync(Promise.reject(new Error('nope'))) + .already.toBeResolved() + ); + }); + + var specExpectations = function(result) { + expect(result.status).toEqual('failed'); + expect(result.failedExpectations.length) + .withContext('Number of failed expectations') + .toEqual(1); + expect(result.failedExpectations[0].message).toEqual( + 'Expected a promise to be resolved but it was ' + + 'rejected with Error: nope.' + ); + expect(result.failedExpectations[0].matcherName) + .withContext('Matcher name') + .not.toEqual(''); + }; + + env.addReporter({ specDone: specExpectations }); + env.execute(null, done); + }); + + it('fails when the promise is pending', function(done) { + jasmine.getEnv().requirePromises(); + + // eslint-disable-next-line compat/compat + var promise = new Promise(function() {}); + + env.it('a spec', function() { + return env.expectAsync(promise).already.toBeResolved(); + }); + + var specExpectations = function(result) { + expect(result.status).toEqual('failed'); + expect(result.failedExpectations.length) + .withContext('Number of failed expectations') + .toEqual(1); + expect(result.failedExpectations[0].message).toEqual( + 'Expected a promise to be settled ' + + '(via expectAsync(...).already) but it was pending.' + ); + expect(result.failedExpectations[0].matcherName) + .withContext('Matcher name') + .not.toEqual(''); + }; + + env.addReporter({ specDone: specExpectations }); + env.execute(null, done); + }); + }); }); diff --git a/src/core/Expectation.js b/src/core/Expectation.js index fb2bcf9e..0309ec34 100644 --- a/src/core/Expectation.js +++ b/src/core/Expectation.js @@ -46,6 +46,12 @@ getJasmineRequireObj().Expectation = function(j$) { * Asynchronous matchers that operate on an actual value which is a promise, * and return a promise. * + * Most async matchers will wait indefinitely for the promise to be resolved + * or rejected, resulting in a spec timeout if that never happens. If you + * expect that the promise will already be resolved or rejected at the time + * the matcher is called, you can use the {@link async-matchers#already} + * modifier to get a faster failure with a more helpful message. + * * Note: Specs must await the result of each async matcher, return the * promise returned by the matcher, or return a promise that's derived from * the one returned by the matcher. Otherwise the matcher will not be @@ -115,6 +121,23 @@ getJasmineRequireObj().Expectation = function(j$) { } }); + /** + * Fail as soon as possible if the actual is pending. + * Otherwise evaluate the matcher. + * @member + * @name async-matchers#already + * @type {async-matchers} + * @example + * await expectAsync(myPromise).already.toBeResolved(); + * @example + * return expectAsync(myPromise).already.toBeResolved(); + */ + Object.defineProperty(AsyncExpectation.prototype, 'already', { + get: function() { + return addFilter(this, expectSettledPromiseFilter); + } + }); + function wrapSyncCompare(name, matcherFactory) { return function() { var result = this.expector.compare(name, matcherFactory, arguments); @@ -193,6 +216,27 @@ getJasmineRequireObj().Expectation = function(j$) { buildFailureMessage: negatedFailureMessage }; + var expectSettledPromiseFilter = { + selectComparisonFunc: function(matcher) { + return function(actual) { + var matcherArgs = arguments; + + return j$.isPending_(actual).then(function(isPending) { + if (isPending) { + return { + pass: false, + message: + 'Expected a promise to be settled (via ' + + 'expectAsync(...).already) but it was pending.' + }; + } else { + return matcher.compare.apply(null, matcherArgs); + } + }); + }; + } + }; + function ContextAddingFilter(message) { this.message = message; } diff --git a/src/core/base.js b/src/core/base.js index 3c1da322..c40c6559 100644 --- a/src/core/base.js +++ b/src/core/base.js @@ -203,6 +203,19 @@ getJasmineRequireObj().base = function(j$, jasmineGlobal) { return matches ? matches[1] : ''; }; + j$.isPending_ = function(promise) { + var sentinel = {}; + // eslint-disable-next-line compat/compat + return Promise.race([promise, Promise.resolve(sentinel)]).then( + function(result) { + return result === sentinel; + }, + function() { + return false; + } + ); + }; + /** * Get a matcher, usable in any {@link matchers|matcher} that uses Jasmine's equality (e.g. {@link matchers#toEqual|toEqual}, {@link matchers#toContain|toContain}, or {@link matchers#toHaveBeenCalledWith|toHaveBeenCalledWith}), * that will succeed if the actual value being compared is an instance of the specified class/constructor.