diff --git a/addon/-private/milestone-coordinator.ts b/addon/-private/milestone-coordinator.ts index 3740d0f..5e9e115 100644 --- a/addon/-private/milestone-coordinator.ts +++ b/addon/-private/milestone-coordinator.ts @@ -1,36 +1,42 @@ import { assert } from '@ember/debug'; -import { CancelableDeferred } from 'ember-milestones'; +import { CancelableDeferred, MilestoneKey } from 'ember-milestones'; import { MilestoneCoordinator as IMilestoneCoordinator } from 'ember-milestones'; import { defer } from './defer'; import MilestoneHandle from './milestone-handle'; import MilestoneTarget from './milestone-target'; /** @hide */ -export const ACTIVE_COORDINATORS: { [key: string]: MilestoneCoordinator } = Object.create(null); +export const ACTIVE_COORDINATORS: { + // TypeScript doesn't allow symbols as an index type, which makes a number of things + // in this module painful :( https://github.com/Microsoft/TypeScript/issues/1863 + [key: string]: MilestoneCoordinator; +} = Object.create(null); /** @hide */ export default class MilestoneCoordinator implements IMilestoneCoordinator { - public static forMilestone(name: string): MilestoneCoordinator | undefined { - return ACTIVE_COORDINATORS[name]; + public static forMilestone(name: MilestoneKey): MilestoneCoordinator | undefined { + return ACTIVE_COORDINATORS[name as any]; } public static deactivateAll(): void { - let keys = Object.keys(ACTIVE_COORDINATORS); - for (; keys.length; keys = Object.keys(ACTIVE_COORDINATORS)) { - ACTIVE_COORDINATORS[keys[0]].deactivateAll(); + for (let key of allKeys(ACTIVE_COORDINATORS)) { + let coordinator = this.forMilestone(key); + if (coordinator) { + coordinator.deactivateAll(); + } } } - public names: string[]; + public names: MilestoneKey[]; private _pendingActions: { [key: string]: { action: () => any, deferred: CancelableDeferred } }; private _nextTarget: MilestoneTarget | null; private _pausedMilestone: MilestoneHandle | null; - constructor(names: string[]) { + constructor(names: MilestoneKey[]) { names.forEach((name) => { - assert(`Milestone '${name}' is already active.`, !ACTIVE_COORDINATORS[name]); - ACTIVE_COORDINATORS[name] = this; + assert(`Milestone '${name.toString()}' is already active.`, !MilestoneCoordinator.forMilestone(name)); + ACTIVE_COORDINATORS[name as any] = this; }); this.names = names; @@ -39,16 +45,16 @@ export default class MilestoneCoordinator implements IMilestoneCoordinator { this._pausedMilestone = null; } - public advanceTo(name: string): MilestoneTarget { - assert(`Milestone '${name}' is not active.`, this.names.indexOf(name) !== -1); + public advanceTo(name: MilestoneKey): MilestoneTarget { + assert(`Milestone '${name.toString()}' is not active.`, this.names.indexOf(name) !== -1); let target = new MilestoneTarget(name); this._nextTarget = target; this._continueAll({ except: name }); - let pending = this._pendingActions[name]; + let pending = this._pendingActions[name as any]; if (pending) { - delete this._pendingActions[name]; + delete this._pendingActions[name as any]; this._targetReached(target, pending.deferred, pending.action); } @@ -59,14 +65,14 @@ export default class MilestoneCoordinator implements IMilestoneCoordinator { this._continueAll(); this.names.forEach((name) => { - delete ACTIVE_COORDINATORS[name]; + delete ACTIVE_COORDINATORS[name as any]; }); this.names = []; } // Called from milestone() - public _milestoneReached>(name: string, action: () => T): T { + public _milestoneReached>(name: MilestoneKey, action: () => T): T { let target = this._nextTarget; // If we're already targeting another milestone, just pass through @@ -78,8 +84,8 @@ export default class MilestoneCoordinator implements IMilestoneCoordinator { if (target && target.name === name) { this._targetReached(target, deferred, action); } else { - assert(`Milestone '${name}' is already pending.`, !this._pendingActions[name]); - this._pendingActions[name] = { deferred, action }; + assert(`Milestone '${name.toString()}' is already pending.`, !this._pendingActions[name as any]); + this._pendingActions[name as any] = { deferred, action }; } // Playing fast and loose with our casting here under the assumption that @@ -101,18 +107,26 @@ export default class MilestoneCoordinator implements IMilestoneCoordinator { target._resolve(this._pausedMilestone); } - private _continueAll({ except }: { except?: string } = {}) { + private _continueAll({ except }: { except?: MilestoneKey } = {}) { let paused = this._pausedMilestone; if (paused && paused.name !== except) { paused.continue(); } - Object.keys(this._pendingActions).forEach((key) => { + allKeys(this._pendingActions).forEach((key) => { if (key === except) { return; } - let { deferred, action } = this._pendingActions[key]; + let { deferred, action } = this._pendingActions[key as any]; deferred.resolve(action()); - delete this._pendingActions[key]; + delete this._pendingActions[key as any]; }); } } + +function allKeys(object: any) { + let keys: MilestoneKey[] = Object.getOwnPropertyNames(object); + if (typeof Object.getOwnPropertySymbols === 'function') { + keys = keys.concat(Object.getOwnPropertySymbols(object)); + } + return keys; +} diff --git a/addon/-private/milestone-handle.ts b/addon/-private/milestone-handle.ts index 2d86a11..e4c45ef 100644 --- a/addon/-private/milestone-handle.ts +++ b/addon/-private/milestone-handle.ts @@ -7,6 +7,7 @@ import MilestoneCoordinator from 'ember-milestones/-private/milestone-coordinato import { CancelableDeferred, MilestoneHandle as IMilestoneHandle, + MilestoneKey, ResolutionOptions, } from 'ember-milestones'; @@ -17,7 +18,7 @@ export default class MilestoneHandle implements IMilestoneHandle { private resolution: Resolution | null = null; constructor( - public name: string, + public name: MilestoneKey, private _coordinator: MilestoneCoordinator, private _action: () => any, private _deferred: CancelableDeferred, @@ -49,7 +50,10 @@ export default class MilestoneHandle implements IMilestoneHandle { } private _complete(resolution: Resolution, options: ResolutionOptions = {}, finalizer: () => void): Promise { - assert(`Multiple resolutions for milestone ${this.name}`, !this.resolution || this.resolution === resolution); + assert( + `Multiple resolutions for milestone '${this.name.toString()}'`, + !this.resolution || this.resolution === resolution, + ); if (!this.resolution) { this.resolution = resolution; diff --git a/addon/-private/milestone-target.ts b/addon/-private/milestone-target.ts index d925af0..6b98049 100644 --- a/addon/-private/milestone-target.ts +++ b/addon/-private/milestone-target.ts @@ -1,6 +1,7 @@ import logger from 'debug'; import { MilestoneHandle, + MilestoneKey, MilestoneTarget as IMilestoneTarget, ResolutionOptions, } from 'ember-milestones'; @@ -13,7 +14,7 @@ export default class MilestoneTarget implements IMilestoneTarget { private _coordinatorDeferred: RSVP.Deferred = defer(); constructor( - public name: string, + public name: MilestoneKey, ) {} public then( diff --git a/addon/index.ts b/addon/index.ts index 2dc9342..e73b227 100644 --- a/addon/index.ts +++ b/addon/index.ts @@ -11,7 +11,7 @@ const debugInactive = logger('ember-milestones:inactive'); * Inactive milestones will pass through to their given callbacks as though the * milestone wrapper weren't present at all... */ -export function activateMilestones(milestones: string[]): MilestoneCoordinator { +export function activateMilestones(milestones: MilestoneKey[]): MilestoneCoordinator { return new CoordinatorImpl(milestones); } @@ -38,10 +38,10 @@ export function deactivateAllMilestones(): void { * * await advanceTo('my-component#poller').andContinue(); */ -export function advanceTo(name: string): MilestoneTarget { +export function advanceTo(name: MilestoneKey): MilestoneTarget { let coordinator = CoordinatorImpl.forMilestone(name); if (!coordinator) { - throw new Error(`Milestone ${name} isn't currently active.`); + throw new Error(`Milestone ${name.toString()} isn't currently active.`); } else { return coordinator.advanceTo(name); } @@ -55,9 +55,9 @@ export function advanceTo(name: string): MilestoneTarget { * When not activated, code wrapped in a milestone is immediately invoked as though * the wrapper weren't there at all. */ -export function milestone>(name: string, callback: () => T): T; -export function milestone(name: string): PromiseLike; -export function milestone(name: string, callback?: () => any): PromiseLike { +export function milestone>(name: MilestoneKey, callback: () => T): T; +export function milestone(name: MilestoneKey): PromiseLike; +export function milestone(name: MilestoneKey, callback?: () => any): PromiseLike { let coordinator = CoordinatorImpl.forMilestone(name); let action = callback || (() => Promise.resolve()); if (coordinator) { @@ -87,7 +87,7 @@ export function milestone(name: string, callback?: () => any): PromiseLike * In most cases, using the importable `advanceTo` should mean you won't need to * use the `as` parameter. */ -export function setupMilestones(hooks: TestHooks, names: string[], options: MilestoneTestOptions = {}) { +export function setupMilestones(hooks: TestHooks, names: MilestoneKey[], options: MilestoneTestOptions = {}) { let milestones: MilestoneCoordinator; hooks.beforeEach(function(this: any) { @@ -103,6 +103,11 @@ export function setupMilestones(hooks: TestHooks, names: string[], options: Mile }); } +/** + * A valid key for a milestone, either a string or a symbol. + */ +export type MilestoneKey = string | symbol; + /** * A `MilestoneCoordinator` is the result of an `activateMilestones` call, * which provides the ability to interact with the milestones you've activated @@ -117,7 +122,7 @@ export interface MilestoneCoordinator { * Advance until the given milestone is reached, continuing past any others * that are active for this coordinator in the meantime. */ - advanceTo(name: string): MilestoneTarget; + advanceTo(name: MilestoneKey): MilestoneTarget; /** * Deactivate all milestones associated with this coordinator. diff --git a/tests/integration/milestones-test.ts b/tests/integration/milestones-test.ts index 69f726f..e469052 100644 --- a/tests/integration/milestones-test.ts +++ b/tests/integration/milestones-test.ts @@ -3,247 +3,260 @@ import { advanceTo, deactivateAllMilestones, milestone, setupMilestones } from ' import { module, test } from 'qunit'; import { resolve } from 'rsvp'; -module('Integration | milestones', function(hooks) { - let program: () => Promise<{ first: number, second: number }>; - let location: string; - - hooks.beforeEach(function() { - location = 'unstarted'; - program = async () => { - location = 'before'; - let first = await milestone('one', async () => { location = 'one-started'; return 1; }); - location = 'one-completed'; - let second = await milestone('two', async () => { location = 'two-started'; return 2; }); - location = 'two-completed'; - return { first, second }; - }; - }); +module('Integration | milestones', function() { + let scenarios = [ + { name: 'with string keys', milestones: ['one', 'two'] }, + { name: 'with symbol keys', milestones: [Symbol('one'), Symbol('two')] }, + { name: 'with mixed keys', milestones: ['one', Symbol('two')] }, + ]; + + for (let { name, milestones: [keyOne, keyTwo] } of scenarios) { + module(name, function(hooks) { + let program: () => Promise<{ first: number, second: number }>; + let location: string; + + hooks.beforeEach(function() { + location = 'unstarted'; + program = async () => { + location = 'before'; + let first = await milestone(keyOne, async () => { location = 'one-started'; return 1; }); + location = 'one-completed'; + let second = await milestone(keyTwo, async () => { location = 'two-started'; return 2; }); + location = 'two-completed'; + return { first, second }; + }; + }); - module('with no milestones active', function() { - test('milestones with callbacks are inert', async function(assert) { - let { first, second } = await program(); - assert.equal(location, 'two-completed'); - assert.equal(first, 1); - assert.equal(second, 2); - }); + module('with no milestones active', function() { + test('milestones with callbacks are inert', async function(assert) { + let { first, second } = await program(); + assert.equal(location, 'two-completed'); + assert.equal(first, 1); + assert.equal(second, 2); + }); - test('milestones without callbacks are inert', async function(assert) { - let program = async () => { - let first = await milestone('one'); - let second = await milestone('two'); - return { first, second }; - }; + test('milestones without callbacks are inert', async function(assert) { + let program = async () => { + let first = await milestone(keyOne); + let second = await milestone(keyTwo); + return { first, second }; + }; - assert.deepEqual(await program(), { - first: undefined, - second: undefined, + assert.deepEqual(await program(), { + first: undefined, + second: undefined, + }); + }); }); - }); - }); - module('with milestones active', function(hooks) { - setupMilestones(hooks, ['one', 'two']); + module('with milestones active', function(hooks) { + setupMilestones(hooks, [keyOne, keyTwo]); - test('skipping a milestone', async function(assert) { - let programPromise = program(); + test('skipping a milestone', async function(assert) { + let programPromise = program(); - let two = await advanceTo('two'); - assert.equal(location, 'one-completed'); + let two = await advanceTo(keyTwo); + assert.equal(location, 'one-completed'); - two.continue(); - assert.equal(location, 'two-started'); - - let { first, second } = await programPromise; - assert.equal(location, 'two-completed'); - assert.equal(first, 1); - assert.equal(second, 2); - }); + two.continue(); + assert.equal(location, 'two-started'); - test('advancing to an already-waiting milestone', async function(assert) { - let programPromise = program(); - assert.equal(location, 'before'); + let { first, second } = await programPromise; + assert.equal(location, 'two-completed'); + assert.equal(first, 1); + assert.equal(second, 2); + }); - await advanceTo('one'); - assert.equal(location, 'before'); + test('advancing to an already-waiting milestone', async function(assert) { + let programPromise = program(); + assert.equal(location, 'before'); - await advanceTo('two').andContinue(); + await advanceTo(keyOne); + assert.equal(location, 'before'); - let { first, second } = await programPromise; - assert.equal(first, 1); - assert.equal(second, 2); - }); + await advanceTo(keyTwo).andContinue(); - test('advancing to a not-yet-waiting milestone', async function(assert) { - let advancePromise = advanceTo('two'); + let { first, second } = await programPromise; + assert.equal(first, 1); + assert.equal(second, 2); + }); - let programPromise = program(); - assert.equal(location, 'one-started'); + test('advancing to a not-yet-waiting milestone', async function(assert) { + let advancePromise = advanceTo(keyTwo); - await advancePromise; - assert.equal(location, 'one-completed'); + let programPromise = program(); + assert.equal(location, 'one-started'); - deactivateAllMilestones(); - await programPromise; - }); + await advancePromise; + assert.equal(location, 'one-completed'); - test('advancing while paused at a previous milestone', async function(assert) { - let programPromise = program(); + deactivateAllMilestones(); + await programPromise; + }); - await advanceTo('one'); - assert.equal(location, 'before'); + test('advancing while paused at a previous milestone', async function(assert) { + let programPromise = program(); - let two = await advanceTo('two'); - assert.equal(location, 'one-completed'); + await advanceTo(keyOne); + assert.equal(location, 'before'); - two.continue(); + let two = await advanceTo(keyTwo); + assert.equal(location, 'one-completed'); - assert.deepEqual(await programPromise, { first: 1, second: 2 }); - }); + two.continue(); - test('continuing occurs in a runloop', async function(assert) { - let program = async () => { - let run = false; - await milestone('one', async () => schedule('actions', () => run = true)); - return run; - }; + assert.deepEqual(await programPromise, { first: 1, second: 2 }); + }); - let programPromise = program(); - let handle = await advanceTo('one'); + test('continuing occurs in a runloop', async function(assert) { + let program = async () => { + let run = false; + await milestone(keyOne, async () => schedule('actions', () => run = true)); + return run; + }; - handle.continue(); - assert.deepEqual(await programPromise, true); - }); + let programPromise = program(); + let handle = await advanceTo(keyOne); - test('stubbing a return value', async function(assert) { - let programPromise = program(); + handle.continue(); + assert.deepEqual(await programPromise, true); + }); - await advanceTo('one').andReturn(111); - await advanceTo('two').andReturn(222); - assert.equal(location, 'two-completed'); + test('stubbing a return value', async function(assert) { + let programPromise = program(); - let { first, second } = await programPromise; - assert.equal(first, 111); - assert.equal(second, 222); - }); + await advanceTo(keyOne).andReturn(111); + await advanceTo(keyTwo).andReturn(222); + assert.equal(location, 'two-completed'); - test('throwing an exception', async function(assert) { - let boom = new Error('boom!'); - let program = async () => { - try { - await milestone('one', async () => 'bad'); - } catch (error) { - return error; - } - }; + let { first, second } = await programPromise; + assert.equal(first, 111); + assert.equal(second, 222); + }); - advanceTo('one').andThrow(boom); - assert.equal(await program(), boom); - }); + test('throwing an exception', async function(assert) { + let boom = new Error('boom!'); + let program = async () => { + try { + await milestone(keyOne, async () => 'bad'); + } catch (error) { + return error; + } + }; + + advanceTo(keyOne).andThrow(boom); + assert.equal(await program(), boom); + }); - test('with no callback', async function(assert) { - let program = async () => { - let first = await milestone('one'); - let second = await milestone('two'); - return { first, second }; - }; + test('with no callback', async function(assert) { + let program = async () => { + let first = await milestone(keyOne); + let second = await milestone(keyTwo); + return { first, second }; + }; + + let programPromise = program(); + await advanceTo(keyOne).andContinue(); + await advanceTo(keyTwo).andContinue(); + assert.deepEqual(await programPromise, { first: undefined, second: undefined }); + }); - let programPromise = program(); - await advanceTo('one').andContinue(); - await advanceTo('two').andContinue(); - assert.deepEqual(await programPromise, { first: undefined, second: undefined }); - }); + test('stepping through each location', async function(assert) { + let programPromise = program(); - test('stepping through each location', async function(assert) { - let programPromise = program(); + let one = await advanceTo(keyOne); + assert.equal(location, 'before'); - let one = await advanceTo('one'); - assert.equal(location, 'before'); + one.continue(); + assert.equal(location, 'one-started'); - one.continue(); - assert.equal(location, 'one-started'); + let two = await advanceTo(keyTwo); + assert.equal(location, 'one-completed'); - let two = await advanceTo('two'); - assert.equal(location, 'one-completed'); + two.continue(); + assert.equal(location, 'two-started'); - two.continue(); - assert.equal(location, 'two-started'); + await programPromise; + assert.equal(location, 'two-completed'); + }); - await programPromise; - assert.equal(location, 'two-completed'); - }); + test('nested milestones', async function(assert) { + let program = async () => { + location = 'before-out'; + let result = await milestone(keyOne, async () => { + location = 'before-in'; + let inner = await milestone(keyTwo, async () => { + location = 'in'; + return 'ok'; + }); + location = 'after-in'; + return inner; + }); + location = 'after-out'; + return result; + }; - test('nested milestones', async function(assert) { - let program = async () => { - location = 'before-out'; - let result = await milestone('one', async () => { - location = 'before-in'; - let inner = await milestone('two', async () => { - location = 'in'; - return 'ok'; - }); - location = 'after-in'; - return inner; - }); - location = 'after-out'; - return result; - }; + let programPromise = program(); - let programPromise = program(); + let one = await advanceTo(keyOne); + assert.equal(location, 'before-out'); - let one = await advanceTo('one'); - assert.equal(location, 'before-out'); + let two = await advanceTo(keyTwo); + assert.equal(location, 'before-in'); - let two = await advanceTo('two'); - assert.equal(location, 'before-in'); + let twoCompletion = two.continue({ immediate: true }); + assert.equal(location, 'in'); - let twoCompletion = two.continue({ immediate: true }); - assert.equal(location, 'in'); + await twoCompletion; + assert.equal(location, 'after-in'); - await twoCompletion; - assert.equal(location, 'after-in'); + await one.continue(); + assert.equal(location, 'after-out'); - await one.continue(); - assert.equal(location, 'after-out'); + assert.equal(await programPromise, 'ok'); + }); - assert.equal(await programPromise, 'ok'); - }); + test('immediate vs deferred continuation', async function(assert) { + let program = async () => { + location = 'before'; - test('immediate vs deferred continuation', async function(assert) { - let program = async () => { - location = 'before'; + let result = await milestone(keyOne, async () => 'ok'); - let result = await milestone('one', async () => 'ok'); + location = 'between'; - location = 'between'; + await resolve(); - await resolve(); + location = 'after'; - location = 'after'; + return result; + }; - return result; - }; + let programPromise = program(); + assert.equal(location, 'before'); + await advanceTo(keyOne).andContinue({ immediate: true }); + assert.equal(location, 'between'); + assert.equal(await programPromise, 'ok'); - let programPromise = program(); - assert.equal(location, 'before'); - await advanceTo('one').andContinue({ immediate: true }); - assert.equal(location, 'between'); - assert.equal(await programPromise, 'ok'); - - programPromise = program(); - assert.equal(location, 'before'); - await advanceTo('one').andContinue(); - assert.equal(location, 'after'); - assert.equal(await programPromise, 'ok'); + programPromise = program(); + assert.equal(location, 'before'); + await advanceTo(keyOne).andContinue(); + assert.equal(location, 'after'); + assert.equal(await programPromise, 'ok'); + }); + }); }); - }); + } module('with multiple milestone sets active', function(hooks) { - setupMilestones(hooks, ['one', 'two']); - setupMilestones(hooks, ['three', 'four']); + let keyOne = Symbol('one'); + let keyFour = Symbol('four'); + + setupMilestones(hooks, [keyOne, 'two']); + setupMilestones(hooks, ['three', keyFour]); test('they can be controlled independently', async function(assert) { let state: { [key: string]: any } = {}; - let program = async (key: string, milestones: string[]) => { + let program = async (key: string, milestones: Array) => { state[key] = 'before'; let first = await milestone(milestones[0], async () => 1); state[key] = 'between'; @@ -252,18 +265,18 @@ module('Integration | milestones', function(hooks) { return first + second; }; - let first = program('first', ['one', 'two']); - let second = program('second', ['three', 'four']); + let first = program('first', [keyOne, 'two']); + let second = program('second', ['three', keyFour]); assert.equal(state.first, 'before'); assert.equal(state.second, 'before'); - await advanceTo('one').andReturn(98); + await advanceTo(keyOne).andReturn(98); assert.equal(state.first, 'between'); assert.equal(state.second, 'before'); - await advanceTo('four').andReturn(9); + await advanceTo(keyFour).andReturn(9); assert.equal(state.first, 'between'); assert.equal(state.second, 'after'); @@ -278,15 +291,15 @@ module('Integration | milestones', function(hooks) { }); test('all active milestones can be deactivated', async function(assert) { - let program = async (milestones: string[]) => { + let program = async (milestones: Array) => { let first = await milestone(milestones[0], async () => 2); let second = await milestone(milestones[1], async () => 3); return first * second; }; - let first = program(['one', 'two']); + let first = program([keyOne, 'two']); deactivateAllMilestones(); - let second = program(['three', 'four']); + let second = program(['three', keyFour]); assert.equal(await first, 6); assert.equal(await second, 6);