diff --git a/karma-js.conf.js b/karma-js.conf.js index 35fbdae03f4d8..a4821472a4e2c 100644 --- a/karma-js.conf.js +++ b/karma-js.conf.js @@ -28,6 +28,7 @@ module.exports = function(config) { 'node_modules/zone.js/dist/zone.js', 'node_modules/zone.js/dist/long-stack-trace-zone.js', + 'node_modules/zone.js/dist/task-tracking.js', 'node_modules/zone.js/dist/proxy.js', 'node_modules/zone.js/dist/sync-test.js', 'node_modules/zone.js/dist/jasmine-patch.js', diff --git a/packages/core/src/testability/testability.ts b/packages/core/src/testability/testability.ts index 3d5a22b0e4e88..731c6b897eb2d 100644 --- a/packages/core/src/testability/testability.ts +++ b/packages/core/src/testability/testability.ts @@ -22,6 +22,25 @@ export declare interface PublicTestability { findProviders(using: any, provider: string, exactMatch: boolean): any[]; } +export interface PendingMacrotask { + source: string; + isPeriodic: boolean; + delay?: number; + creationLocation: Error; + xhr?: XMLHttpRequest; +} + +export type DoneCallback = (didWork: boolean, tasks?: PendingMacrotask[]) => void; +export type UpdateCallback = (tasks: PendingMacrotask[]) => boolean; + +interface WaitCallback { + // Needs to be 'any' - setTimeout returns a number according to ES6, but + // on NodeJS it returns a Timer. + timeoutId: any; + doneCb: DoneCallback; + updateCb?: UpdateCallback; +} + /** * The Testability service provides testing hooks that can be accessed from * the browser and by services such as Protractor. Each bootstrapped Angular @@ -30,23 +49,25 @@ export declare interface PublicTestability { */ @Injectable() export class Testability implements PublicTestability { - /** @internal */ - _pendingCount: number = 0; - /** @internal */ - _isZoneStable: boolean = true; + private _pendingCount: number = 0; + private _isZoneStable: boolean = true; /** * Whether any work was done since the last 'whenStable' callback. This is * useful to detect if this could have potentially destabilized another * component while it is stabilizing. * @internal */ - _didWork: boolean = false; - /** @internal */ - _callbacks: Function[] = []; - constructor(private _ngZone: NgZone) { this._watchAngularEvents(); } + private _didWork: boolean = false; + private _callbacks: WaitCallback[] = []; - /** @internal */ - _watchAngularEvents(): void { + private taskTrackingZone: any; + + constructor(private _ngZone: NgZone) { + this._watchAngularEvents(); + _ngZone.run(() => { this.taskTrackingZone = Zone.current.get('TaskTrackingZone'); }); + } + + private _watchAngularEvents(): void { this._ngZone.onUnstable.subscribe({ next: () => { this._didWork = true; @@ -67,12 +88,14 @@ export class Testability implements PublicTestability { }); } + /** @deprecated pending requests are now tracked with zones */ increasePendingRequestCount(): number { this._pendingCount += 1; this._didWork = true; return this._pendingCount; } + /** @deprecated pending requests are now tracked with zones */ decreasePendingRequestCount(): number { this._pendingCount -= 1; if (this._pendingCount < 0) { @@ -83,27 +106,81 @@ export class Testability implements PublicTestability { } isStable(): boolean { - return this._isZoneStable && this._pendingCount == 0 && !this._ngZone.hasPendingMacrotasks; + return this._isZoneStable && this._pendingCount === 0 && !this._ngZone.hasPendingMacrotasks; } - /** @internal */ - _runCallbacksIfReady(): void { + private _runCallbacksIfReady(): void { if (this.isStable()) { // Schedules the call backs in a new frame so that it is always async. scheduleMicroTask(() => { while (this._callbacks.length !== 0) { - (this._callbacks.pop() !)(this._didWork); + let cb = (this._callbacks.pop() as WaitCallback); + clearTimeout(cb.timeoutId); + cb.doneCb(this._didWork); } this._didWork = false; }); } else { - // Not Ready + // Still not stable, send updates. + let pending = this.getPendingTasks(); + this._callbacks = this._callbacks.filter((cb) => { + if (cb.updateCb && cb.updateCb(pending)) { + clearTimeout(cb.timeoutId); + return false; + } + + return true; + }); + this._didWork = true; } } - whenStable(callback: Function): void { - this._callbacks.push(callback); + private getPendingTasks(): PendingMacrotask[] { + if (!this.taskTrackingZone) { + throw new Error('Task tracking zone required when using whenStable() with a timeout!'); + } + + return this.taskTrackingZone.macroTasks.map((t: Task) => { + return { + source: t.source, + isPeriodic: t.data.isPeriodic, + delay: t.data.delay, + // From TaskTrackingZone: + // https://github.com/angular/zone.js/blob/master/lib/zone-spec/task-tracking.ts#L40 + creationLocation: (t as any).creationLocation as Error, + // Added by Zones for XHRs + // https://github.com/angular/zone.js/blob/master/lib/browser/browser.ts#L133 + xhr: (t.data as any).target + }; + }); + } + + private addCallback(cb: DoneCallback, timeout?: number, updateCb?: UpdateCallback) { + let timeoutId: any = -1; + if (timeout && timeout > 0) { + timeoutId = setTimeout(() => { + this._callbacks = this._callbacks.filter((cb) => cb.timeoutId !== timeoutId); + cb(this._didWork, this.getPendingTasks()); + }, timeout); + } + this._callbacks.push({doneCb: cb, timeoutId: timeoutId, updateCb: updateCb}); + } + + /** + * Wait for angular to be stable with a timeout. If the timeout is hit before Angular becomes + * stable, the callback receives a list of the macro tasks that were pending, otherwise null. + * + * @param doneCb The callback to invoke when Angular is stable or the timeout expires + * whichever comes first. + * @param timeout Optional. The maximum time to wait for Angular to become stable. If not + * specified, whenStable() will wait forever. + * @param updateCb Optional. If specified, this callback will be invoked whenever the set of + * pending macrotasks changes. If this callback returns true doneCb will not be invoked. + * + */ + whenStable(doneCb: DoneCallback, timeout?: number, updateCb?: UpdateCallback): void { + this.addCallback(doneCb, timeout, updateCb); this._runCallbacksIfReady(); } diff --git a/packages/core/src/zone/ng_zone.ts b/packages/core/src/zone/ng_zone.ts index 07b85c49e2e85..597e690c8a821 100644 --- a/packages/core/src/zone/ng_zone.ts +++ b/packages/core/src/zone/ng_zone.ts @@ -107,6 +107,10 @@ export class NgZone { this.inner = this.inner.fork((Zone as any)['wtfZoneSpec']); } + if ((Zone as any)['TaskTrackingZoneSpec']) { + this.inner = this.inner.fork(new ((Zone as any)['TaskTrackingZoneSpec'] as any)); + } + if (enableLongStackTrace && (Zone as any)['longStackTraceZoneSpec']) { this.inner = this.inner.fork((Zone as any)['longStackTraceZoneSpec']); } diff --git a/packages/core/test/testability/testability_spec.ts b/packages/core/test/testability/testability_spec.ts index 17fe3658305de..128a4b0add974 100644 --- a/packages/core/test/testability/testability_spec.ts +++ b/packages/core/test/testability/testability_spec.ts @@ -8,22 +8,10 @@ import {EventEmitter} from '@angular/core'; import {Injectable} from '@angular/core/src/di'; -import {Testability} from '@angular/core/src/testability/testability'; +import {PendingMacrotask, Testability} from '@angular/core/src/testability/testability'; import {NgZone} from '@angular/core/src/zone/ng_zone'; -import {AsyncTestCompleter, SpyObject, beforeEach, describe, expect, inject, it} from '@angular/core/testing/src/testing_internal'; - -import {scheduleMicroTask} from '../../src/util'; - - - -// Schedules a microtasks (using a resolved promise .then()) -function microTask(fn: Function): void { - scheduleMicroTask(() => { - // We do double dispatch so that we can wait for scheduleMicrotask in the Testability when - // NgZone becomes stable. - scheduleMicroTask(fn); - }); -} +import {fakeAsync, tick} from '@angular/core/testing'; +import {SpyObject, beforeEach, describe, expect, it} from '@angular/core/testing/src/testing_internal'; @Injectable() class MockNgZone extends NgZone { @@ -64,13 +52,11 @@ export function main() { it('should start with a pending count of 0', () => { expect(testability.getPendingRequestCount()).toEqual(0); }); - it('should fire whenstable callbacks if pending count is 0', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + it('should fire whenstable callbacks if pending count is 0', fakeAsync(() => { testability.whenStable(execute); - microTask(() => { - expect(execute).toHaveBeenCalled(); - async.done(); - }); + tick(); + + expect(execute).toHaveBeenCalled(); })); it('should not fire whenstable callbacks synchronously if pending count is 0', () => { @@ -78,37 +64,29 @@ export function main() { expect(execute).not.toHaveBeenCalled(); }); - it('should not call whenstable callbacks when there are pending counts', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + it('should not call whenstable callbacks when there are pending counts', fakeAsync(() => { testability.increasePendingRequestCount(); testability.increasePendingRequestCount(); testability.whenStable(execute); - microTask(() => { - expect(execute).not.toHaveBeenCalled(); - testability.decreasePendingRequestCount(); + tick(0); + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); - microTask(() => { - expect(execute).not.toHaveBeenCalled(); - async.done(); - }); - }); + tick(0); + expect(execute).not.toHaveBeenCalled(); })); - it('should fire whenstable callbacks when pending drops to 0', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + it('should fire whenstable callbacks when pending drops to 0', fakeAsync(() => { testability.increasePendingRequestCount(); testability.whenStable(execute); - microTask(() => { - expect(execute).not.toHaveBeenCalled(); - testability.decreasePendingRequestCount(); + tick(); + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); - microTask(() => { - expect(execute).toHaveBeenCalled(); - async.done(); - }); - }); + tick(); + expect(execute).toHaveBeenCalled(); })); it('should not fire whenstable callbacks synchronously when pending drops to 0', () => { @@ -119,47 +97,140 @@ export function main() { expect(execute).not.toHaveBeenCalled(); }); - it('should fire whenstable callbacks with didWork if pending count is 0', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + it('should fire whenstable callbacks with didWork if pending count is 0', fakeAsync(() => { testability.whenStable(execute); - microTask(() => { - expect(execute).toHaveBeenCalledWith(false); - async.done(); - }); + tick(0); + expect(execute).toHaveBeenCalledWith(false); })); - it('should fire whenstable callbacks with didWork when pending drops to 0', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + it('should fire whenstable callbacks with didWork when pending drops to 0', fakeAsync(() => { testability.increasePendingRequestCount(); testability.whenStable(execute); - microTask(() => { - testability.decreasePendingRequestCount(); + tick(); + testability.decreasePendingRequestCount(); - microTask(() => { - expect(execute).toHaveBeenCalledWith(true); - testability.whenStable(execute2); + tick(); + expect(execute).toHaveBeenCalledWith(true); + testability.whenStable(execute2); - microTask(() => { - expect(execute2).toHaveBeenCalledWith(false); - async.done(); - }); - }); - }); + tick(); + expect(execute2).toHaveBeenCalledWith(false); })); + }); describe('NgZone callback logic', () => { - it('should fire whenstable callback if event is already finished', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + describe('whenStable with timeout', () => { + it('should list pending tasks when the timeout is hit', fakeAsync(() => { + const id = ngZone.run(() => setTimeout(() => {}, 1000)); + testability.whenStable(execute, 200); + + expect(execute).not.toHaveBeenCalled(); + tick(200); + expect(execute).toHaveBeenCalled(); + const tasks = execute.calls.mostRecent().args[1] as PendingMacrotask[]; + + expect(tasks[0].delay).toEqual(1000); + expect(tasks[0].source).toEqual('setTimeout'); + expect(tasks[0].isPeriodic).toEqual(false); + + clearTimeout(id); + })); + + it('should fire if Angular is already stable', fakeAsync(() => { + testability.whenStable(execute, 200); + tick(); + + expect(execute).toHaveBeenCalled(); + })); + + it('should fire when macroTasks are cancelled', fakeAsync(() => { + const id = ngZone.run(() => setTimeout(() => {}, 1000)); + testability.whenStable(execute, 500); + + tick(200); + ngZone.run(() => clearTimeout(id)); + // fakeAsync doesn't trigger NgZones whenStable + ngZone.stable(); + + tick(1); + expect(execute).toHaveBeenCalled(); + })); + + it('calls the done callback when angular is stable', fakeAsync(() => { + let timeout1Done = false; + ngZone.run(() => setTimeout(() => timeout1Done = true, 500)); + testability.whenStable(execute, 1000); + + tick(600); + ngZone.stable(); + tick(); + + expect(timeout1Done).toEqual(true); + expect(execute).toHaveBeenCalled(); + + // Should cancel the done timeout. + tick(500); + ngZone.stable(); + tick(); + expect(execute.calls.count()).toEqual(1); + })); + + + it('calls update when macro tasks change', fakeAsync(() => { + let timeout1Done = false; + let timeout2Done = false; + ngZone.run(() => setTimeout(() => timeout1Done = true, 500)); + testability.whenStable(execute, 1000, execute2); + + tick(100); + ngZone.run(() => setTimeout(() => timeout2Done = true, 300)); + // fakeAsync doesn't trigger NgZone's whenStable + ngZone.stable(); + tick(400); + ngZone.stable(); + tick(); + + expect(timeout1Done).toEqual(true); + expect(timeout2Done).toEqual(true); + expect(execute2.calls.count()).toEqual(2); + expect(execute).toHaveBeenCalled(); + + const update1 = execute2.calls.all()[0].args[0] as PendingMacrotask[]; + expect(update1[0].delay).toEqual(500); + + const update2 = execute2.calls.all()[1].args[0] as PendingMacrotask[]; + expect(update2[0].delay).toEqual(500); + expect(update2[1].delay).toEqual(300); + })); + + it('cancels the done callback if the update callback returns true', fakeAsync(() => { + let timeoutDone = false; + ngZone.unstable(); + execute2.and.returnValue(true); + testability.whenStable(execute, 1000, execute2); + + tick(100); + ngZone.run(() => setTimeout(() => timeoutDone = true, 500)); + ngZone.stable(); + expect(execute2).toHaveBeenCalled(); + + tick(500); + ngZone.stable(); + tick(); + + expect(execute).not.toHaveBeenCalled(); + })); + }); + + it('should fire whenstable callback if event is already finished', fakeAsync(() => { ngZone.unstable(); ngZone.stable(); testability.whenStable(execute); - microTask(() => { - expect(execute).toHaveBeenCalled(); - async.done(); - }); + tick(); + expect(execute).toHaveBeenCalled(); })); it('should not fire whenstable callbacks synchronously if event is already finished', () => { @@ -170,20 +241,16 @@ export function main() { expect(execute).not.toHaveBeenCalled(); }); - it('should fire whenstable callback when event finishes', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + it('should fire whenstable callback when event finishes', fakeAsync(() => { ngZone.unstable(); testability.whenStable(execute); - microTask(() => { - expect(execute).not.toHaveBeenCalled(); - ngZone.stable(); + tick(); + expect(execute).not.toHaveBeenCalled(); + ngZone.stable(); - microTask(() => { - expect(execute).toHaveBeenCalled(); - async.done(); - }); - }); + tick(); + expect(execute).toHaveBeenCalled(); })); it('should not fire whenstable callbacks synchronously when event finishes', () => { @@ -194,91 +261,72 @@ export function main() { expect(execute).not.toHaveBeenCalled(); }); - it('should not fire whenstable callback when event did not finish', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + it('should not fire whenstable callback when event did not finish', fakeAsync(() => { ngZone.unstable(); testability.increasePendingRequestCount(); testability.whenStable(execute); - microTask(() => { - expect(execute).not.toHaveBeenCalled(); - testability.decreasePendingRequestCount(); - - microTask(() => { - expect(execute).not.toHaveBeenCalled(); - ngZone.stable(); - - microTask(() => { - expect(execute).toHaveBeenCalled(); - async.done(); - }); - }); - }); + tick(); + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); + + tick(); + expect(execute).not.toHaveBeenCalled(); + ngZone.stable(); + + tick(); + expect(execute).toHaveBeenCalled(); })); - it('should not fire whenstable callback when there are pending counts', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + it('should not fire whenstable callback when there are pending counts', fakeAsync(() => { ngZone.unstable(); testability.increasePendingRequestCount(); testability.increasePendingRequestCount(); testability.whenStable(execute); - microTask(() => { - expect(execute).not.toHaveBeenCalled(); - ngZone.stable(); + tick(); + expect(execute).not.toHaveBeenCalled(); + ngZone.stable(); - microTask(() => { - expect(execute).not.toHaveBeenCalled(); - testability.decreasePendingRequestCount(); - - microTask(() => { - expect(execute).not.toHaveBeenCalled(); - testability.decreasePendingRequestCount(); - - microTask(() => { - expect(execute).toHaveBeenCalled(); - async.done(); - }); - }); - }); - }); + tick(); + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); + + tick(); + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); + + tick(); + expect(execute).toHaveBeenCalled(); })); it('should fire whenstable callback with didWork if event is already finished', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + fakeAsync(() => { ngZone.unstable(); ngZone.stable(); testability.whenStable(execute); - microTask(() => { - expect(execute).toHaveBeenCalledWith(true); - testability.whenStable(execute2); + tick(); + expect(execute).toHaveBeenCalledWith(true); + testability.whenStable(execute2); - microTask(() => { - expect(execute2).toHaveBeenCalledWith(false); - async.done(); - }); - }); + tick(); + expect(execute2).toHaveBeenCalledWith(false); })); - it('should fire whenstable callback with didwork when event finishes', - inject([AsyncTestCompleter], (async: AsyncTestCompleter) => { + it('should fire whenstable callback with didwork when event finishes', fakeAsync(() => { ngZone.unstable(); testability.whenStable(execute); - microTask(() => { - ngZone.stable(); + tick(); + ngZone.stable(); - microTask(() => { - expect(execute).toHaveBeenCalledWith(true); - testability.whenStable(execute2); + tick(); + expect(execute).toHaveBeenCalledWith(true); + testability.whenStable(execute2); - microTask(() => { - expect(execute2).toHaveBeenCalledWith(false); - async.done(); - }); - }); - }); + tick(); + expect(execute2).toHaveBeenCalledWith(false); })); }); });