Last active
August 29, 2015 14:17
-
-
Save m0rjc/023880d05979847e3a52 to your computer and use it in GitHub Desktop.
My second attempt at Javascript Promises (aka Futures), in native Javascript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Implementation of Javascript Promises. | |
* A simplified version of the U4.Promise framework I wrote for Unit4 in 2013. | |
* Building it up as I need it. | |
*/ | |
var Promise = (function(){ | |
/** | |
* @private | |
* Copy properties from one object to another. | |
* @param target | |
* @param source | |
*/ | |
function applyProperties(target, source) { | |
for(var key in source) { | |
if(source.hasOwnProperty(key)) { | |
target[key] = source[key]; | |
} | |
} | |
} | |
/** | |
* Call handler for every own property in obj. Works on arrays and objects | |
* @param obj | |
* @param handler | |
* @param thisArg | |
*/ | |
function each (obj, handler, thisArg) { | |
var key; | |
for (key in obj){ | |
if(obj.hasOwnProperty(key)){ | |
handler.call(thisArg || this, obj[key]); | |
} | |
} | |
} | |
/** | |
* Call handler for every own property in obj, until handler returns truthy. | |
* @param obj | |
* @param handler | |
* @param thisArg | |
*/ | |
function some (obj, handler, thisArg) { | |
var key; | |
for (key in obj){ | |
if(obj.hasOwnProperty(key)){ | |
if(handler.call(thisArg || this, obj[key])){ | |
break; | |
} | |
} | |
} | |
} | |
/** | |
* Create a copy of obj, applying a transform to each own property. | |
* @param obj | |
* @param transform | |
* @param thisArg | |
* @returns {Array} | |
*/ | |
function map (obj, transform, thisArg) { | |
var result = Array.isArray(obj) ? [] : {}, | |
key; | |
for (key in obj){ | |
if(obj.hasOwnProperty(key)){ | |
result[key] = transform.call(thisArg || this, obj[key]); | |
} | |
} | |
return result; | |
} | |
/** | |
* @param x | |
* @returns {*|boolean|Boolean} true if x is a Promise. | |
*/ | |
function isPromise(x) { | |
return x && x.isPromise; | |
} | |
/** | |
* Do nothing. | |
*/ | |
function noOperation() {} | |
/** | |
* Base Promise class. | |
* @constructor | |
*/ | |
function Promise() { | |
this._promiseConstructor(); | |
return this; | |
} | |
Promise.prototype = { | |
/** @property {Boolean} isPromise marker to say it's a promise. */ | |
isPromise: true, | |
/** @property {Boolean} resolved true if the promise is resolved. */ | |
resolved: false, | |
/** @property {Boolean} rejected true if the promise is rejected. */ | |
rejected: false, | |
/** @property {*} result result if we have one. */ | |
result: undefined, | |
/** @property {*} error error if we have one. */ | |
error: undefined, | |
_promiseConstructor : function () { | |
/** @property {Promise[]} _next ongoing promises in success case. */ | |
this._next = []; | |
/** @property {Promise[]} _otherwise ongoing promises in fail case. */ | |
this._otherwise = []; | |
}, | |
/** | |
* Register something to do when the promise resolves. | |
* @param {Function} handler handler function to call. | |
* @param {*} handler.result the upstream result | |
* @param {Promise|*} handler.returns handler returns its result or a Promise of its result. | |
* @param {Object} [scope] scope to call handler in. | |
* @returns {Promise} promise of the handler's completion. | |
*/ | |
then: function (handler, scope) { | |
return this.thenResolve(new OngoingPromise(handler, scope)); | |
}, | |
/** | |
* Register another Promise as something to do when this promise resolves. | |
* The Promise will be rejected if this promise rejects. | |
* @param downstreamPromise | |
* @returns {*} | |
*/ | |
thenResolve: function (downstreamPromise) { | |
var me = this; | |
if(me.resolved) { | |
downstreamPromise.onUpstreamResolved(me.result); | |
} else if (me.rejected) { | |
downstreamPromise.onUpstreamRejected(me.error); | |
} else { | |
me._next.push(downstreamPromise); | |
} | |
return downstreamPromise; | |
}, | |
/** | |
* Register something to do when the promise Rejects. | |
* @param handler | |
* @param [scope] | |
* @returns {*} | |
*/ | |
otherwise: function (handler, scope) { | |
return this.otherwiseResolve(new OngoingPromise(handler, scope)); | |
}, | |
/** | |
* Register another Promise as something to do when this promise rejects. | |
* The Promise will only be resolved if this promise or an upstream rejects. | |
* @param {Promise} downstreamPromise | |
*/ | |
otherwiseResolve: function (downstreamPromise) { | |
var me = this; | |
if (me.rejected) { | |
downstreamPromise.onUpstreamResolved(me.error); | |
} else if (!me.resolved) { | |
me._otherwise.push(downstreamPromise); | |
} | |
return downstreamPromise; | |
}, | |
/** | |
* Register something to do regardless of the result of the promise. | |
* @param {Function} handler function to call | |
* @param {Object} handler.arg composite containing the result | |
* @param {Object} handler.arg.result success result. | |
* @param {Object} handler.arg.error error result. | |
* @param {Boolean} handler.arg.isSuccess true if success | |
* @param {Object} [scope] scope for the handler | |
* @returns {Promise} promise of the handler completing. | |
*/ | |
thenAlways: function (handler, scope) { | |
return this.alwaysResolve(new OngoingPromise(handler, scope)); | |
}, | |
/** | |
* Register a promise to call regardless of the outcome of this promise. | |
* The result will be a composite as described in {@link #thenAlways}. | |
* @param {Promise} downstreamPromise | |
*/ | |
alwaysResolve: function (downstreamPromise) { | |
this.then(function(result){ | |
downstreamPromise.onUpstreamResolved({ | |
isSuccess: true, | |
result: result | |
}); | |
}); | |
this.otherwise(function(error){ | |
downstreamPromise.onUpstreamResolved({ | |
isSuccess: false, | |
error: error | |
}); | |
}); | |
return downstreamPromise; | |
}, | |
/** | |
* If an error transforms through this point then it can be transformed. | |
* Allows something like "The warp drive is broken." to be transformed to | |
* "Problem jumping into hyperspace. The warp drive is broken." | |
* | |
* @param {Function} transform method to transform the error | |
* @param {*} transform.error the error being passed. | |
* @param {!Promise} transform.return the transformed error. This must be synchronous. | |
* @param {Object} [scope] scope for the transform function. | |
*/ | |
transformError: function (transform, scope) { | |
return this.thenResolve(new TransformErrorPromise(transform, scope)); | |
}, | |
/** | |
* @protected | |
* Handle an upstream promise resolving. | |
* The base class immediately rejects itself. | |
* @param {*} error the error from the upstream promise. | |
*/ | |
onUpstreamResolved: function (result) { | |
this.resolve(result); | |
}, | |
/** | |
* @protected | |
* Handle an upstream promise rejecting. | |
* The base class immediately resolves itself. | |
* @param {*} result the result from the upstream promise. | |
*/ | |
onUpstreamRejected: function (error) { | |
this.reject(error); | |
}, | |
/** | |
* Resolve the Promise. Can only be called once. | |
* @param {Promise|*} [result] the result or the promise of the result. | |
*/ | |
resolve: function (result) { | |
if (result && result.isPromise) { | |
result.thenResolve(this); | |
} else { | |
this.resolved = true; | |
this.result = result; | |
this._next.forEach(function (promise) { | |
promise.onUpstreamResolved(result); | |
}); | |
this.inhibitFutureInput(); | |
} | |
}, | |
inhibitFutureInput: function() { | |
delete this._next; | |
delete this._otherwise; | |
this.resolve = noOperation; | |
this.reject = noOperation; | |
}, | |
/** | |
* Reject the Promise. Can only be called once. | |
* @param {*|!Promise} [error] error result. Cannot be a promise. | |
*/ | |
reject: function (error) { | |
this.rejected = true; | |
this.error = error; | |
this._next.forEach(function (promise) { | |
promise.onUpstreamRejected(error); | |
}); | |
this._otherwise.forEach(function (promise) { | |
promise.onUpstreamResolved(error); | |
}); | |
this.inhibitFutureInput(); | |
}, | |
/** | |
* @return {Boolean} true if resolved or rejected | |
*/ | |
isComplete: function() { | |
return this.resolved || this.rejected; | |
} | |
}; | |
/** | |
* @static | |
* Convenience function to create a rejected promise. When returned in a Promise handler, causes a | |
* reject. | |
* @param error the error to reject with. | |
*/ | |
Promise.reject = function(error) { | |
var promise = new Promise(); | |
promise.reject(error); | |
return promise; | |
}; | |
/** | |
* @static | |
* Convert an unknown into a Promise. | |
* @param {Promise|*} result if a Promise then wait on that promise, otherwise return a promise resolved with the result. | |
* @returns {Promise} | |
*/ | |
Promise.resolve = function(result) { | |
var promise; | |
if(isPromise(result)){ | |
return result; | |
} | |
promise = new Promise(); | |
promise.resolve(result); | |
return promise; | |
}; | |
/** | |
* @private a promise with a handler, downstream of an original promise. | |
* @param handler | |
* @param scope | |
* @constructor | |
*/ | |
function OngoingPromise(handler, scope){ | |
this._promiseConstructor(); | |
this.handler = handler; | |
this.scope = scope; | |
} | |
OngoingPromise.prototype = new Promise(); | |
applyProperties(OngoingPromise.prototype, { | |
onUpstreamResolved: function(result) { | |
var nextResult; | |
try { | |
nextResult = this.handler.call(this.scope || this, result); | |
// If we re-enter onUpstreamResolved it is because nextResult | |
// was a Promise, so we want to resolve immediately. | |
this.onUpstreamResolved = this.resolve; | |
this.resolve(nextResult); | |
} catch (e) { | |
this.reject(e); | |
} | |
} | |
}); | |
/** | |
* @private | |
* A promise that transforms errors that pass through it. | |
* @param {Function} transform | |
* @param {Object} [scope] | |
* @constructor | |
*/ | |
function TransformErrorPromise(transform, scope){ | |
this._promiseConstructor(); | |
this.transform = transform; | |
this.scope = scope; | |
} | |
TransformErrorPromise.prototype = new Promise(); | |
applyProperties(TransformErrorPromise.prototype, { | |
onUpstreamRejected: function (error) { | |
var newError; | |
try { | |
newError = this.transform.call(this.scope || this, error); | |
} catch (e) { | |
newError = 'Error transforming error. Original error was: ' + error; | |
} | |
this.reject(newError); | |
} | |
}); | |
/** | |
* @static | |
* Create a Promise which resolves when all requirements have resolved. **Fast Reject Version** | |
* | |
* The Result will be a Map or Array matching Requirements with the Promise results in each place. | |
* If Rejected then the Error that from the first found rejection. The Promise will reject as soon as | |
* an upstream Promise rejects. | |
* | |
* @param {Object|Array} requirements promises or results to wait for. | |
* then we will wait and collect all results. | |
* | |
* If fastReject is true then the rejection passed down will be the first one encountered with no information about | |
* where it came from. If it is false then the rejection will be an Object/Array of promises based on the original | |
* requirement to allow interrogation. | |
*/ | |
Promise.when = function(requirements) { | |
return new JoinPromise(requirements, true); | |
}; | |
/** | |
* @static | |
* Create a Promise which resolves when all requirements have **Slow Reject Version** | |
* | |
* The Result will be a Map or Array matching Requirements with the Promise results in each place. | |
* If Rejected then the Error will be the same Map or Array of each Promise. Promises will be created | |
* to wrap non-Promise inputs. | |
* | |
* @param {Object|Array} requirements promises or results to wait for. | |
* then we will wait and collect all results. | |
* | |
* If fastReject is true then the rejection passed down will be the first one encountered with no information about | |
* where it came from. If it is false then the rejection will be an Object/Array of promises based on the original | |
* requirement to allow interrogation. | |
*/ | |
Promise.whenAllWithSlowReject = function(requirements) { | |
return new JoinPromise(requirements, false); | |
}; | |
/** | |
* @private | |
* @param requirements | |
* @param fastReject | |
* @constructor | |
*/ | |
function JoinPromise(requirements, fastReject) { | |
this.requirements = requirements; | |
this.fastReject = !!fastReject; | |
this.onUpstreamStateChange(); | |
each(requirements, function(requirement){ | |
if (isPromise(requirement)) { | |
// This comes into our upstreamResolve method which is overridden | |
// to calculate the real state. | |
requirement.alwaysResolve(this); | |
} | |
}, this) | |
} | |
JoinPromise.prototype = new Promise(); | |
applyProperties(JoinPromise.prototype, { | |
onUpstreamStateChange: function () { | |
var allOk = true, | |
allComplete = true, | |
someError = false, | |
requirements = this.requirements; | |
if(!this.isComplete()){ | |
each(requirements, function(requirement){ | |
if(isPromise(requirement)) { | |
allOk &= requirement.resolved; | |
allComplete &= (requirement.rejected || requirement.resolved); | |
someError |= requirement.rejected; | |
} | |
}); | |
if(allOk){ | |
this.doResolve(); | |
} else if(allComplete || (this.fastReject && someError)){ | |
this.doReject(); | |
} | |
} | |
}, | |
doResolve: function(){ | |
this.resolve(map(this.requirements, function(requirement){ | |
return isPromise(requirement) ? requirement.result : requirement; | |
})); | |
}, | |
doReject: function(){ | |
if(this.fastReject){ | |
// Reject with the first error found. | |
var error = undefined; | |
some(this.requirements, function(requirement){ | |
if(requirement && isPromise(requirement) && requirement.rejected){ | |
error = requirement.error; | |
return true; | |
} | |
}); | |
this.reject(error); | |
} else { | |
// Reject with full information. | |
this.reject(map(this.requirements, function(requirement){ | |
return Promise.resolve(requirement); | |
})); | |
} | |
}, | |
onUpstreamResolved: function(){ | |
this.onUpstreamStateChange(); | |
}, | |
onUpstreamRejected: function(){ | |
this.onUpstreamStateChange(); | |
} | |
}); | |
return Promise; | |
})(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
describe('Promise', function() { | |
/** Promise handler to add one to the input. */ | |
function addOne(x){return x+1;} | |
/** Promise handler to multiply the input by two. */ | |
function timesTwo(x){return x*2; } | |
/** Promise handler that throws 'An Exception' */ | |
function throwException(x){throw 'An Exception';} | |
/** Promise handler that returns a Rejected promise with message 'Reject'. */ | |
function returnReject(x){return Promise.reject('Reject');} | |
/** Create a promise handler to store its input on its scope with the given key, and pass that input on. */ | |
function storeResult(key){ | |
return function(x){this[key] = x; return x;} | |
} | |
/** Create a prmise handler to increment a counter in its scope, then pass its input on. */ | |
function increment(key){ | |
return function(){this[key]++; return x;} | |
} | |
function assertPromiseReady(promise){ | |
expect(promise.resolved).toBe(false, 'assertPromiseReady: expected resolved to be false'); | |
expect(promise.rejected).toBe(false, 'assertPromiseReady: rejected resolved to be false'); | |
expect(promise.isComplete()).toBe(false, 'assertPromiseReady: expected isComplete() to be false'); | |
expect(promise.result).toBeUndefined('assertPromiseReady: expected result to be undefined'); | |
expect(promise.error).toBeUndefined('assertPromiseReady: expected error to be undefined'); | |
} | |
function assertPromiseResolved(promise, expectedResult) { | |
expect(promise.resolved).toBe(true, 'assertPromiseResolved: expected resolved to be true'); | |
expect(promise.rejected).toBe(false, 'assertPromiseResolved: rejected resolved to be false'); | |
expect(promise.isComplete()).toBe(true, 'assertPromiseResolved: expected isComplete() to be true'); | |
expect(promise.result).toEqual(expectedResult, 'assertPromiseResolved: expected result'); | |
expect(promise.error).toBeUndefined('assertPromiseResolved: expected error to be undefined'); | |
} | |
function assertPromiseRejected(promise, expectedError) { | |
expect(promise.resolved).toBe(false, 'assertPromiseRejected: expected resolved to be false'); | |
expect(promise.rejected).toBe(true, 'assertPromiseRejected: expected rejected to be true'); | |
expect(promise.isComplete()).toBe(true, 'assertPromiseRejected: expected isComplete() to be true'); | |
expect(promise.result).toBeUndefined('assertPromiseRejected: expected result to be undefined'); | |
expect(promise.error).toEqual(expectedError, 'assertPromiseRejected: expected error'); | |
} | |
describe('new Promise()', function() { | |
it('is initially unresolved and unrejected', function () { | |
var p = new Promise(); | |
assertPromiseReady(p); | |
}); | |
}); | |
describe('resolve()', function(){ | |
it('becomes resolved and complete once resolved', function(){ | |
var p = new Promise(); | |
p.resolve('Fred'); | |
assertPromiseResolved(p, 'Fred'); | |
}); | |
it('can accept no argument as an answer', function(){ | |
var p = new Promise(); | |
p.resolve(); | |
assertPromiseResolved(p, undefined); | |
}); | |
it('can accept a complex argument as an answer', function(){ | |
var p = new Promise(), | |
expectedResult = {foo:'bar'}; | |
p.resolve(expectedResult); | |
assertPromiseResolved(p, expectedResult); | |
expect(p.result).toBe(expectedResult); // No clones | |
}); | |
it('can be resolved with a Promise, so allowing casting to Promise', function(){ | |
var promise1 = new Promise(), | |
promise2 = new Promise(); | |
promise1.then(timesTwo).then(storeResult('result'), this); | |
promise1.resolve(promise2); | |
assertPromiseReady(promise1); | |
promise2.resolve(5); | |
assertPromiseResolved(promise1, 5); // Initial value resolved with | |
expect(this.result).toBe(10); // End result of the chain. | |
}); | |
it('can be resolved with a Promise, fires immediately if the Promise is already resolved', function(){ | |
var promise1 = new Promise(), | |
promise2 = new Promise(); | |
promise2.resolve(5); | |
promise1.then(timesTwo).then(storeResult('result'), this); | |
promise1.resolve(promise2); | |
assertPromiseResolved(promise1, 5); // Initial value resolved with | |
expect(this.result).toBe(10); // End result of the chain. | |
}); | |
it('if already resolved resists further attempts to resolve it', function(){ | |
var p = new Promise(), | |
result = { | |
count: 0 | |
}; | |
p.then(storeResult('result'), result).then(increment('count'), result); | |
p.resolve('x'); | |
expect(result).toEqual({count: 1, result: 'x'}); | |
p.resolve('y'); | |
expect(result).toEqual({count: 1, result: 'x'}); | |
assertPromiseResolved(p, 'x'); | |
}); | |
it('if already rejected resists further attempts to resolve it', function(){ | |
var p = new Promise(), | |
result = { | |
count: 0 | |
}; | |
p.then(storeResult('result'), result).then(increment('count'), result); | |
p.reject('x'); | |
p.resolve('y'); | |
expect(result).toEqual({count: 0}); | |
assertPromiseRejected(p, 'x'); | |
}); | |
}); | |
describe('reject()', function(){ | |
it('becomes rejected and complete once rejected', function(){ | |
var p = new Promise(); | |
p.reject('An Error'); | |
assertPromiseRejected(p, 'An Error'); | |
}); | |
it('can accept no argument as an error', function(){ | |
var p = new Promise(); | |
p.reject(); | |
assertPromiseRejected(p, undefined); | |
}); | |
it('can accept a complex argument as an answer', function(){ | |
var p = new Promise(), | |
expectedResult = {error: true, msg:'bar'}; | |
p.reject(expectedResult); | |
assertPromiseRejected(p, expectedResult); | |
expect(p.error).toBe(expectedResult); // No clones | |
}); | |
it('if already resolved resists further attempts to reject it', function(){ | |
var p = new Promise(), | |
result = { | |
count: 0 | |
}; | |
p.otherwise(storeResult('error'), result).then(increment('count'), result); | |
p.reject('x'); | |
expect(result).toEqual({count: 1, error: 'x'}); | |
p.reject('y'); | |
expect(result).toEqual({count: 1, error: 'x'}); | |
assertPromiseRejected(p, 'x'); | |
}); | |
it('if already rejected resists further attempts to resolve it', function(){ | |
var p = new Promise(), | |
result = { | |
count: 0 | |
}; | |
p.then(storeResult('result'), result).then(increment('count'), result) | |
.otherwise(storeResult('error'), result).then(increment('count'), result); | |
p.reject('x'); | |
expect(result).toEqual({count: 1, error: 'x'}); | |
p.resolve('y'); | |
expect(result).toEqual({count: 1, error: 'x'}); | |
assertPromiseRejected(p, 'x'); | |
}); | |
}); | |
describe('then()', function() { | |
it('can call multiple handlers once resolved', function () { | |
var p = new Promise(), | |
result1 = '--UNSET--', | |
result2 = '--UNSET--'; | |
p.then(function (x) { | |
result1 = x; | |
}); | |
p.then(function (x) { | |
result2 = x; | |
}); | |
expect(result1).toBe('--UNSET--'); | |
p.resolve(12); | |
expect(result1).toBe(12); | |
expect(result2).toBe(12); | |
}); | |
it('accepts a scope parameter for handlers', function () { | |
var p = new Promise(), | |
capturedScope = '--UNSET--', | |
requiredScope = {foo: 'Bar'}; | |
p.then(function () { | |
capturedScope = this | |
}, requiredScope); | |
p.resolve(12); | |
expect(capturedScope).toBe(requiredScope); | |
}); | |
it('calls an added handler immediately if already resolved', function () { | |
var p = new Promise(), | |
result = '--UNSET--'; | |
p.resolve(34); | |
p.then(function (x) { | |
result = x; | |
}); | |
expect(result).toBe(34); | |
}); | |
it('does not call its handlers once rejected', function () { | |
var p = new Promise(), | |
result = '--UNSET--'; | |
p.then(function (x) { | |
result = x; | |
}); | |
p.reject('An Error'); | |
expect(result).toBe('--UNSET--'); | |
}); | |
}); | |
describe('otherwise()', function() { | |
it('can call multiple error handlers once rejected', function () { | |
var p = new Promise(), | |
result1 = '--UNSET--', | |
result2 = '--UNSET--'; | |
p.otherwise(function (x) { | |
result1 = x; | |
}); | |
p.otherwise(function (x) { | |
result2 = x; | |
}); | |
expect(result1).toBe('--UNSET--'); | |
p.reject('An Error'); | |
expect(result1).toBe('An Error'); | |
expect(result2).toBe('An Error'); | |
}); | |
it('accepts a scope parameter for handlers', function () { | |
var p = new Promise(), | |
capturedScope = '--UNSET--', | |
requiredScope = {foo: 'Bar'}; | |
p.otherwise(function () { | |
capturedScope = this | |
}, requiredScope); | |
p.reject('An Error'); | |
expect(capturedScope).toBe(requiredScope); | |
}); | |
it('calls an added error handler immediately once rejected', function () { | |
var p = new Promise(), | |
result = '--UNSET--'; | |
p.reject('An Error'); | |
p.otherwise(function(x){ | |
result = x; | |
}); | |
expect(result).toBe('An Error'); | |
}); | |
it('does not call its error handlers once resolved', function () { | |
var p = new Promise(), | |
result = '--UNSET--'; | |
p.otherwise(function (x) { | |
result = x; | |
}); | |
p.resolve('The Result'); | |
expect(result).toBe('--UNSET--'); | |
}); | |
}); | |
describe('thenAlways()', function() { | |
it('can call a handler for resolve and reject, resolve case', function(){ | |
var p = new Promise(), | |
result = '--UNSET--'; | |
p.thenAlways(function(x){ | |
result = x; | |
}); | |
p.resolve('Result'); | |
expect(result).toEqual({ | |
result: 'Result', | |
isSuccess: true | |
}); | |
}); | |
it('can call a handler for resolve and reject, resolve case - previously resolved', function(){ | |
var p = new Promise(), | |
result = '--UNSET--'; | |
p.resolve('Result'); | |
p.thenAlways(function(x){ | |
result = x; | |
}); | |
expect(result).toEqual({ | |
result: 'Result', | |
isSuccess: true | |
}); | |
}); | |
it('can call a handler for resolve and reject, reject case', function(){ | |
var p = new Promise(), | |
result = '--UNSET--'; | |
p.thenAlways(function(x){ | |
result = x; | |
}); | |
p.reject('An Error'); | |
expect(result).toEqual({ | |
error: 'An Error', | |
isSuccess: false | |
}); | |
}); | |
it('can call a handler for resolve and reject, reject case - previously rejected', function(){ | |
var p = new Promise(), | |
result = '--UNSET--'; | |
p.reject('An Error'); | |
p.thenAlways(function(x){ | |
result = x; | |
}); | |
expect(result).toEqual({ | |
error: 'An Error', | |
isSuccess: false | |
}); | |
}); | |
// These were not expected to work seeing the code. They were found to work | |
// because of work done to prevent re-entry of Promises. As long as all tests | |
// in this section pass then we can be confident. TDD means we don't need to | |
// solve it as it already works. | |
it('does not call the handler twice on reject', function(){ | |
var p = new Promise(), | |
result = {count: 0}; | |
p.thenAlways(increment('count'), result); | |
p.reject('Foo') | |
expect(result.count).toBe(1); | |
}); | |
it('does not call the handler twice on prior reject', function(){ | |
var p = new Promise(), | |
result = {count: 0}; | |
p.reject('Foo') | |
p.thenAlways(increment('count'), result); | |
expect(result.count).toBe(1); | |
}); | |
it('does not call the handler twice on prior resolve', function(){ | |
var p = new Promise(), | |
result = {count: 0}; | |
p.resolve('Foo') | |
p.thenAlways(increment('count'), result); | |
expect(result.count).toBe(1); | |
}); | |
}); | |
describe('Promise Chaining', function(){ | |
it('Promises can be chained to produce a new Promise of the result of its handler', function(){ | |
var firstPromise = new Promise(), | |
lastPromise = firstPromise.then(addOne).then(timesTwo).then(storeResult('Result'), this); | |
firstPromise.resolve(10); | |
expect(this.Result).toBe(22); | |
assertPromiseResolved(lastPromise, 22); | |
}); | |
it('Promises can be chained to produce a new Promise of the result of its handler - Previously Resolve Case', function(){ | |
var firstPromise = new Promise(), | |
lastPromise; | |
firstPromise.resolve(10); | |
lastPromise = firstPromise.then(addOne).then(timesTwo).then(storeResult('Result'), this); | |
expect(this.Result).toBe(22); | |
assertPromiseResolved(lastPromise, 22); | |
}); | |
it('propagates rejects down the chain', function(){ | |
var firstPromise = new Promise(), | |
lastPromise = firstPromise.then(addOne).then(timesTwo), | |
error = { error: true, message: 'An Error' }; | |
lastPromise.otherwise(storeResult('capturedError'), this); | |
firstPromise.reject(error); | |
expect(this.capturedError).toBe(error); // Not a clone. | |
assertPromiseRejected(lastPromise, error); | |
}); | |
it('A Promise can be rejected by throwing an exception in the handler', function(){ | |
var firstPromise = new Promise(), | |
lastPromise = firstPromise.then(throwException).then(timesTwo); | |
lastPromise.otherwise(storeResult('capturedError'), this); | |
firstPromise.resolve(10); | |
expect(this.capturedError).toBe('An Exception'); | |
assertPromiseRejected(lastPromise, 'An Exception'); | |
}); | |
it('A Promise can be rejected by returning Promise.reject() in the handler', function(){ | |
var firstPromise = new Promise(), | |
lastPromise = firstPromise.then(returnReject).then(timesTwo); | |
lastPromise.otherwise(storeResult('capturedError'), this); | |
firstPromise.resolve(10); | |
expect(this.capturedError).toBe('Reject'); | |
assertPromiseRejected(lastPromise, 'Reject'); | |
}); | |
it('will wait for a Promise returned by a handler to be resolved, then resolve', function(){ | |
var initialPromise = new Promise(), | |
innerPromise = new Promise(), | |
finalPromise; | |
finalPromise = initialPromise.then(function(){return innerPromise;}).then(storeResult('result'), this); | |
assertPromiseReady(finalPromise); | |
initialPromise.resolve('Outer Result'); | |
assertPromiseReady(finalPromise); | |
innerPromise.resolve('Inner Result'); | |
assertPromiseResolved(finalPromise, 'Inner Result'); | |
expect(this.result).toBe('Inner Result'); | |
}); | |
it('will all a hander to return a pre-resolved promise, then resolve', function(){ | |
var initialPromise = new Promise(), | |
innerPromise = new Promise(), | |
finalPromise; | |
innerPromise.resolve('Inner Result'); | |
finalPromise = initialPromise.then(function(){return innerPromise;}).then(storeResult('result'), this); | |
assertPromiseReady(finalPromise); | |
initialPromise.resolve('Outer Result'); | |
assertPromiseResolved(finalPromise, 'Inner Result'); | |
expect(this.result).toBe('Inner Result'); | |
}); | |
it('will wait for a Promise returned by a handler to be rejected, then reject', function(){ | |
var initialPromise = new Promise(), | |
innerPromise = new Promise(), | |
finalPromise; | |
finalPromise = initialPromise.then(function(){return innerPromise;}); | |
finalPromise.otherwise(storeResult('capturedError'), this); | |
initialPromise.resolve(); | |
innerPromise.reject('Inner Error'); | |
assertPromiseRejected(finalPromise, 'Inner Error'); | |
expect(this.capturedError).toBe('Inner Error'); | |
}); | |
}); | |
describe('thenResolve()', function() { | |
it('can be asked to resolve another promise when resolved. Returns that Promise', function () { | |
var promise1 = new Promise(), | |
promise2 = new Promise(), | |
results = {}; | |
promise2.then(storeResult('p2'), results); | |
promise1.thenResolve(promise2).then(timesTwo).then(storeResult('endResult'), results); | |
assertPromiseReady(promise1); | |
assertPromiseReady(promise2); | |
promise1.resolve(2); | |
assertPromiseResolved(promise1, 2); // Initial value resolved with | |
assertPromiseResolved(promise2, 2); // Initial value resolved with | |
expect(results.p2).toBe(2); // Promise2 is resolved with the result of Promise1 | |
expect(results.endResult).toBe(4); | |
}); | |
it('resolves immediately if the promise is already resolved', function () { | |
var promise1 = new Promise(), | |
promise2 = new Promise(), | |
results = {}; | |
promise2.then(storeResult('p2'), results); | |
promise1.resolve(2); | |
promise1.thenResolve(promise2).then(timesTwo).then(storeResult('endResult'), results); | |
assertPromiseResolved(promise1, 2); // Initial value resolved with | |
assertPromiseResolved(promise2, 2); // Initial value resolved with | |
expect(results.p2).toBe(2); // Promise2 is resolved with the result of Promise1 | |
expect(results.endResult).toBe(4); | |
}); | |
it('rejects the other promise if this promise is rejected', function(){ | |
var promise1 = new Promise(), | |
promise2 = new Promise(), | |
results = {}; | |
promise2.then(storeResult('p2'), results).otherwise(storeResult('p2Error'), results); | |
promise1.thenResolve(promise2); | |
promise1.reject('An Error'); | |
assertPromiseRejected(promise1, 'An Error'); | |
assertPromiseRejected(promise2, 'An Error'); | |
expect(results.p2).toBeUndefined(); | |
expect(results.p2Error).toBe('An Error'); | |
}); | |
it('rejects the other promise immediately if this promise is already rejected', function(){ | |
var promise1 = new Promise(), | |
promise2 = new Promise(), | |
results = {}; | |
promise2.then(storeResult('p2'), results).otherwise(storeResult('p2Error'), results); | |
promise1.reject('An Error'); | |
promise1.thenResolve(promise2); | |
assertPromiseRejected(promise1, 'An Error'); | |
assertPromiseRejected(promise2, 'An Error'); | |
expect(results.p2).toBeUndefined(); | |
expect(results.p2Error).toBe('An Error'); | |
}); | |
}); | |
describe('otherwiseResolve()', function(){ | |
it('can be asked to resolve another promise when rejected. Returns that Promise', function(){ | |
var promise1 = new Promise(), | |
promise2 = new Promise(), | |
results = {}; | |
promise2.then(storeResult('p2'), results); | |
promise1.otherwiseResolve(promise2).then(timesTwo).then(storeResult('endResult'), results); | |
assertPromiseReady(promise1); | |
assertPromiseReady(promise2); | |
promise1.reject(2); | |
assertPromiseRejected(promise1, 2); // Initial value resolved with | |
assertPromiseResolved(promise2, 2); // Initial value resolved with | |
expect(results.p2).toBe(2); // Promise2 is resolved with the result of Promise1 | |
expect(results.endResult).toBe(4); | |
}); | |
it('rejects immediately if the promise is already rejected', function(){ | |
var promise1 = new Promise(), | |
promise2 = new Promise(), | |
results = {}; | |
promise2.then(storeResult('p2'), results); | |
promise1.reject(2); | |
promise1.otherwiseResolve(promise2).then(timesTwo).then(storeResult('endResult'), results); | |
assertPromiseRejected(promise1, 2); // Initial value resolved with | |
assertPromiseResolved(promise2, 2); // Initial value resolved with | |
expect(results.p2).toBe(2); // Promise2 is resolved with the result of Promise1 | |
expect(results.endResult).toBe(4); | |
}); | |
/** | |
* Rejection is assumed for error handling not flow control, so we don't start off rejecting | |
* an error chain in the inital promise succeeded. | |
*/ | |
it('does not reject the "otherwise Promise" when resolved', function(){ | |
var promise1 = new Promise(), | |
promise2 = new Promise(), | |
results = {}; | |
promise2.then(storeResult('p2'), results); | |
promise1.otherwiseResolve(promise2).then(timesTwo).then(storeResult('endResult'), results); | |
assertPromiseReady(promise1); | |
assertPromiseReady(promise2); | |
promise1.resolve(2); | |
assertPromiseResolved(promise1, 2); // Initial value resolved with | |
assertPromiseReady(promise2); // Never completes. | |
expect(results.endResult).toBeUndefined(); | |
}); | |
}); | |
describe('transformError()', function(){ | |
it('can transform an error passing through a chain of Promises', function(){ | |
var externalSystemCall = new Promise(); | |
function someServiceCall(){ | |
return externalSystemCall | |
.then(addOne) | |
.transformError(function(e){ | |
return 'Problem adding one to an external result: ' + e; | |
}); | |
} | |
someServiceCall().otherwise(storeResult('capturedFinalError'), this); | |
externalSystemCall.reject('External Service Error'); | |
expect(this.capturedFinalError).toBe('Problem adding one to an external result: External Service Error'); | |
}); | |
}); | |
describe('Promise.resolve() - can be used to turn an unknown into a Promise', function(){ | |
it('accepts a value and returns a resolved promise of that value', function(){ | |
var p = Promise.resolve(2); | |
assertPromiseResolved(p, 2); | |
}); | |
it('accepts no argument and returns a resolved promise of undefined value', function(){ | |
var p = Promise.resolve(); | |
assertPromiseResolved(p, undefined); | |
}); | |
it('accepts a Promise and returns that same Promise', function(){ | |
var p1 = new Promise(), | |
p2 = Promise.resolve(p1); | |
expect(p2).toBe(p1); | |
}); | |
}); | |
describe('Promise.when() - Joining promises', function(){ | |
it('can wait on multiple Promises, success case, Object requirements', function(){ | |
var p1 = new Promise(), | |
p2 = new Promise(), | |
p3 = new Promise(), | |
joined = Promise.when({ | |
p1: p1, | |
p2: p2, | |
p3: p3 | |
}); | |
assertPromiseReady(joined); | |
p1.resolve(1); | |
assertPromiseReady(joined); | |
p3.resolve(3); | |
assertPromiseReady(joined); | |
p2.resolve(2); | |
assertPromiseResolved(joined, { | |
p1: 1, | |
p2: 2, | |
p3: 3 | |
}); | |
}); | |
it('can wait on a mix of Promises and literals, success case, Object requirements', function(){ | |
var p1 = new Promise(), | |
p3 = new Promise(), | |
joined = Promise.when({ | |
p1: p1, | |
literal: 'Hello', | |
p3: p3 | |
}); | |
assertPromiseReady(joined); | |
p1.resolve(1); | |
assertPromiseReady(joined); | |
p3.resolve(3); | |
assertPromiseResolved(joined, { | |
p1: 1, | |
literal: 'Hello', | |
p3: 3 | |
}); | |
}); | |
it('can wait on a mix of Promises and literals when a literal is undefined', function(){ | |
var p1 = new Promise(), | |
p3 = new Promise(), | |
joined = Promise.when({ | |
p1: p1, | |
literal: undefined, | |
p3: p3 | |
}); | |
assertPromiseReady(joined); | |
p1.resolve(1); | |
assertPromiseReady(joined); | |
p3.resolve(3); | |
assertPromiseResolved(joined, { | |
p1: 1, | |
literal: undefined, | |
p3: 3 | |
}) | |
}); | |
it('can wait on multiple Promises, success case, Array requirements', function(){ | |
var p1 = new Promise(), | |
p2 = new Promise(), | |
p3 = new Promise(), | |
joined = Promise.when([p1, p2, p3]); | |
assertPromiseReady(joined); | |
p1.resolve(1); | |
assertPromiseReady(joined); | |
p3.resolve(3); | |
assertPromiseReady(joined); | |
p2.resolve(2); | |
assertPromiseResolved(joined, [1, 2, 3]); | |
}); | |
it('can wait on multiple Promises, success case, Array requirements with literals', function(){ | |
var p1 = new Promise(), | |
p3 = new Promise(), | |
joined = Promise.when([p1, 2, p3]); | |
assertPromiseReady(joined); | |
p1.resolve(1); | |
assertPromiseReady(joined); | |
p3.resolve(3); | |
assertPromiseResolved(joined, [1, 2, 3]); | |
}); | |
it('can wait on multiple Promises, success case, Object requirements, all previously resolved', function(){ | |
var p = Promise.when({ | |
p1: Promise.resolve(1), | |
p2: Promise.resolve(2), | |
pu: Promise.resolve(), | |
l1: 'Literal' | |
}); | |
assertPromiseResolved(p, { | |
p1: 1, | |
p2: 2, | |
pu: undefined, | |
l1: 'Literal' | |
}); | |
}); | |
it('can wait on multiple Promises, error case, slow reject, Object requirements - rejects with Object containing Promises', function(){ | |
var p1 = new Promise(), | |
p2 = new Promise(), | |
p3 = new Promise(), | |
joined = Promise.whenAllWithSlowReject({ | |
p1: p1, | |
p2: p2, | |
p3: p3, | |
l1: 'Literal' | |
}); | |
assertPromiseReady(joined); | |
p1.reject('E1'); | |
assertPromiseReady(joined); | |
p2.resolve(2); | |
assertPromiseReady(joined); | |
p3.resolve(3); | |
assertPromiseRejected(joined, { | |
p1: p1, | |
p2: p2, | |
p3: p3, | |
l1: Promise.resolve('Literal') | |
}); | |
}); | |
it('can wait on multiple Promises, error case, slow reject, Array Requirements - rejects with Promise array', function(){ | |
var p1 = new Promise(), | |
p2 = new Promise(), | |
p3 = new Promise(), | |
joined = Promise.whenAllWithSlowReject([p1, p2, p3, 'Literal']); | |
assertPromiseReady(joined); | |
p1.reject('E1'); | |
assertPromiseReady(joined); | |
p2.resolve(2); | |
assertPromiseReady(joined); | |
p3.resolve(3); | |
assertPromiseRejected(joined, [p1, p2, p3, Promise.resolve('Literal')]); | |
}); | |
it('can wait on multiple Promises, error case, fast reject, Object requirements - rejects with single error', function(){ | |
var p1 = new Promise(), | |
p2 = new Promise(), | |
p3 = new Promise(), | |
joined = Promise.when({ | |
p1: p1, | |
p2: p2, | |
p3: p3, | |
l1: 'Literal' | |
}); | |
assertPromiseReady(joined); | |
p1.resolve(1); | |
assertPromiseReady(joined); | |
p2.reject('E2'); | |
assertPromiseRejected(joined, 'E2'); | |
}); | |
it('can wait on multiple Promises, error case, fast reject, Array requirements - rejects with single error', function(){ | |
var p1 = new Promise(), | |
p2 = new Promise(), | |
p3 = new Promise(), | |
joined = Promise.when([p1, p2, p3, 'Literal']); | |
assertPromiseReady(joined); | |
p1.resolve(1); | |
assertPromiseReady(joined); | |
p2.reject('E2'); | |
assertPromiseRejected(joined, 'E2'); | |
}); | |
it('can wait on multiple Promises - once fast reject has fired future results are ignored', function(){ | |
var p1 = new Promise(), | |
p2 = new Promise(), | |
p3 = new Promise(), | |
p4 = new Promise(), | |
joined = Promise.when([p1, p2, p3, 'Literal', p4]), | |
captureCount = 0; | |
function capture(){ | |
captureCount++; | |
} | |
joined.thenAlways(capture); | |
assertPromiseReady(joined); | |
p1.resolve(1); | |
assertPromiseReady(joined); | |
p2.reject('E2'); | |
assertPromiseRejected(joined, 'E2'); | |
p3.reject('E3'); | |
assertPromiseRejected(joined, 'E2'); | |
p4.resolve(4); | |
assertPromiseRejected(joined, 'E2'); | |
expect(captureCount).toBe(1); | |
}); | |
it('can wait on multiple Promises, error case, Object requirements, all previously rejected - rejects immediately', function(){ | |
var p1 = Promise.reject('E1'), | |
p2 = Promise.reject('E2'); | |
Promise.whenAllWithSlowReject({ | |
p1: p1, | |
p2: p2 | |
}).otherwise(storeResult('result'), this); | |
expect(this.result).toEqual({ | |
p1: p1, | |
p2: p2 | |
}); | |
}); | |
it('can wait on multiple Promises, error case, Object requirements, fast reject, some previously rejected - rejects immediately', function(){ | |
var result = {}; | |
Promise.when({ | |
p1: Promise.reject('E1'), | |
p2: new Promise(), | |
l1: 'Literal' | |
}).otherwise(storeResult('error'), result); | |
expect(result.error).toBe('E1'); | |
}); | |
}); | |
}); |
Implemented joining and unit tests.
Failing Tests: Error handling when joining promises using Promise.when
is incomplete.
I've spent some time at work porting this to Ext-JS and in doing some found some ways it can be better. It all passes the unit tests (assuming I've uploaded latest which I can do) but there are things that can be Better. Given time I can make the tweaks - notably:
- The subclass prototypes get the Promise initialiser, so carry the lists. Either lazy instantiate the lists or delete them from the prototype. Does not cause a problem.
- A condition rather than overwriting method pointers makes sense to inhibit future firing. It was an interesting idea, but the condition more understandable.
- The
then
method can better follow Promises-A and take a fail handler as second argument allowingthen(handler, [failHandler], [scope])
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is not complete. I've not needed JoinPromise yet in my gallery app, so have not written it.