From 4e0a2c9c93312f86f55d1770284a26aaf123c0cc Mon Sep 17 00:00:00 2001 From: Michael Giambalvo Date: Thu, 27 Apr 2017 11:44:14 -0700 Subject: [PATCH] feat(core): add task tracking to the Testability API Allow passing an optional timeout to Testability's whenStable(). If specified, if Angular is not stable before the timeout is hit, the done callback will be invoked with a list of pending macrotasks. Also, allows an optional update callback, which will be invoked whenever the set of pending macrotasks changes. If this callback returns true, the timeout will be cancelled and the done callback will not be invoked. --- karma-js.conf.js | 1 + packages/core/src/testability/testability.ts | 111 +++++- packages/core/src/zone/ng_zone.ts | 4 + .../core/test/testability/testability_spec.ts | 316 ++++++++++-------- 4 files changed, 281 insertions(+), 151 deletions(-) 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); })); }); });