diff --git a/modules/angular2/manual_typings/globals-es6.d.ts b/modules/angular2/manual_typings/globals-es6.d.ts index b1292515c2..c6e348aa79 100644 --- a/modules/angular2/manual_typings/globals-es6.d.ts +++ b/modules/angular2/manual_typings/globals-es6.d.ts @@ -30,6 +30,7 @@ interface BrowserNodeGlobal { zone: Zone; getAngularTestability: Function; getAllAngularTestabilities: Function; + frameworkStabilizers: Array; setTimeout: Function; clearTimeout: Function; setInterval: Function; diff --git a/modules/angular2/src/core/testability/testability.ts b/modules/angular2/src/core/testability/testability.ts index ebd9dcd829..f8cb22bf47 100644 --- a/modules/angular2/src/core/testability/testability.ts +++ b/modules/angular2/src/core/testability/testability.ts @@ -15,6 +15,13 @@ import {PromiseWrapper, ObservableWrapper} from 'angular2/src/facade/async'; export class Testability { /** @internal */ _pendingCount: number = 0; + /** + * 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[] = []; /** @internal */ @@ -23,8 +30,10 @@ export class Testability { /** @internal */ _watchAngularEvents(_ngZone: NgZone): void { - ObservableWrapper.subscribe(_ngZone.onTurnStart, - (_) => { this._isAngularEventPending = true; }); + ObservableWrapper.subscribe(_ngZone.onTurnStart, (_) => { + this._didWork = true; + this._isAngularEventPending = true; + }); _ngZone.runOutsideAngular(() => { ObservableWrapper.subscribe(_ngZone.onEventDone, (_) => { @@ -38,6 +47,7 @@ export class Testability { increasePendingRequestCount(): number { this._pendingCount += 1; + this._didWork = true; return this._pendingCount; } @@ -55,14 +65,16 @@ export class Testability { /** @internal */ _runCallbacksIfReady(): void { if (!this.isStable()) { + this._didWork = true; return; // Not ready } // Schedules the call backs in a new frame so that it is always async. PromiseWrapper.resolve(null).then((_) => { while (this._callbacks.length !== 0) { - (this._callbacks.pop())(); + (this._callbacks.pop())(this._didWork); } + this._didWork = false; }); } diff --git a/modules/angular2/src/platform/browser/testability.dart b/modules/angular2/src/platform/browser/testability.dart index 8dbccb26d5..54ab25618e 100644 --- a/modules/angular2/src/platform/browser/testability.dart +++ b/modules/angular2/src/platform/browser/testability.dart @@ -90,7 +90,7 @@ class PublicTestability implements _JsObjectProxyable { 'findBindings': (bindingString, [exactMatch, allowNonElementNodes]) => findBindings(bindingString, exactMatch, allowNonElementNodes), 'isStable': () => isStable(), - 'whenStable': (callback) => whenStable(() => callback.apply([])) + 'whenStable': (callback) => whenStable((didWork) => callback.apply([didWork])) })..['_dart_'] = this; } } @@ -116,16 +116,38 @@ class BrowserGetTestability implements GetTestability { } throw 'Could not find testability for element.'; }); - js.context['getAllAngularTestabilities'] = _jsify(() { + var getAllAngularTestabilities = () { var registry = js.context['ngTestabilityRegistries']; var result = []; for (int i = 0; i < registry.length; i++) { var testabilities = - registry[i].callMethod('getAllAngularTestabilities'); + registry[i].callMethod('getAllAngularTestabilities'); if (testabilities != null) result.addAll(testabilities); } return _jsify(result); + }; + js.context['getAllAngularTestabilities'] = + _jsify(getAllAngularTestabilities); + + var whenAllStable = _jsify((callback) { + var testabilities = getAllAngularTestabilities(); + var count = testabilities.length; + var didWork = false; + var decrement = _jsify((bool didWork_) { + didWork = didWork || didWork_; + count--; + if (count == 0) { + callback.apply([didWork]); + } + }); + testabilities.forEach((testability) { + testability.callMethod('whenStable', [decrement]); + }); }); + if (js.context['frameworkStabilizers'] == null) { + js.context['frameworkStabilizers'] = new js.JsArray(); + } + js.context['frameworkStabilizers'].add(whenAllStable); } jsRegistry.add(this._createRegistry(registry)); } @@ -163,4 +185,4 @@ class BrowserGetTestability implements GetTestability { }); return object; } -} \ No newline at end of file +} diff --git a/modules/angular2/src/platform/browser/testability.ts b/modules/angular2/src/platform/browser/testability.ts index bb35c488e6..5d1a637779 100644 --- a/modules/angular2/src/platform/browser/testability.ts +++ b/modules/angular2/src/platform/browser/testability.ts @@ -48,6 +48,25 @@ export class BrowserGetTestability implements GetTestability { var testabilities = registry.getAllTestabilities(); return testabilities.map((testability) => { return new PublicTestability(testability); }); }; + + var whenAllStable = (callback) => { + var testabilities = global.getAllAngularTestabilities(); + var count = testabilities.length; + var didWork = false; + var decrement = function(didWork_) { + didWork = didWork || didWork_; + count--; + if (count == 0) { + callback(didWork); + } + }; + testabilities.forEach(function(testability) { testability.whenStable(decrement); }); + }; + + if (!global.frameworkStabilizers) { + global.frameworkStabilizers = ListWrapper.createGrowableSize(0); + } + global.frameworkStabilizers.push(whenAllStable); } findTestabilityInTree(registry: TestabilityRegistry, elem: any, diff --git a/modules/angular2/test/core/testability/testability_spec.ts b/modules/angular2/test/core/testability/testability_spec.ts index c9a7418e3d..1f1862f935 100644 --- a/modules/angular2/test/core/testability/testability_spec.ts +++ b/modules/angular2/test/core/testability/testability_spec.ts @@ -43,12 +43,13 @@ class MockNgZone extends NgZone { export function main() { describe('Testability', () => { - var testability, execute, ngZone; + var testability, execute, execute2, ngZone; beforeEach(() => { ngZone = new MockNgZone(); testability = new Testability(ngZone); execute = new SpyObject().spy('execute'); + execute2 = new SpyObject().spy('execute'); }); describe('Pending count logic', () => { @@ -109,6 +110,35 @@ export function main() { expect(execute).not.toHaveBeenCalled(); }); + + it('should fire whenstable callbacks with didWork if pending count is 0', + inject([AsyncTestCompleter], (async) => { + testability.whenStable(execute); + microTask(() => { + expect(execute).toHaveBeenCalledWith(false); + async.done(); + }); + })); + + it('should fire whenstable callbacks with didWork when pending drops to 0', + inject([AsyncTestCompleter], (async) => { + testability.increasePendingRequestCount(); + testability.whenStable(execute); + + microTask(() => { + testability.decreasePendingRequestCount(); + + microTask(() => { + expect(execute).toHaveBeenCalledWith(true); + testability.whenStable(execute2); + + microTask(() => { + expect(execute2).toHaveBeenCalledWith(false); + async.done(); + }); + }); + }); + })); }); describe('NgZone callback logic', () => { @@ -208,6 +238,43 @@ export function main() { }); }); })); + + it('should fire whenstable callback with didWork if event is already finished', + inject([AsyncTestCompleter], (async) => { + ngZone.start(); + ngZone.finish(); + testability.whenStable(execute); + + microTask(() => { + expect(execute).toHaveBeenCalledWith(true); + testability.whenStable(execute2); + + microTask(() => { + expect(execute2).toHaveBeenCalledWith(false); + async.done(); + }); + }); + })); + + it('should fire whenstable callback with didwork when event finishes', + inject([AsyncTestCompleter], (async) => { + ngZone.start(); + testability.whenStable(execute); + + microTask(() => { + ngZone.finish(); + + microTask(() => { + expect(execute).toHaveBeenCalledWith(true); + testability.whenStable(execute2); + + microTask(() => { + expect(execute2).toHaveBeenCalledWith(false); + async.done(); + }); + }); + }); + })); }); }); } diff --git a/modules/playground/e2e_test/async/async_spec.ts b/modules/playground/e2e_test/async/async_spec.ts index 64c5f4d55f..18f783890b 100644 --- a/modules/playground/e2e_test/async/async_spec.ts +++ b/modules/playground/e2e_test/async/async_spec.ts @@ -58,5 +58,35 @@ describe('async', () => { expect(timeout.$('.val').getText()).toEqual('10'); }); + it('should wait via frameworkStabilizer', () => { + var whenAllStable = function() { + return browser.executeAsyncScript('window.frameworkStabilizers[0](arguments[0]);'); + }; + + // This disables protractor's wait mechanism + browser.ignoreSynchronization = true; + + var timeout = $('#multiDelayedIncrements'); + + // At this point, the async action is still pending, so the count should + // still be 0. + expect(timeout.$('.val').getText()).toEqual('0'); + + timeout.$('.action').click(); + + whenAllStable().then((didWork) => { + // whenAllStable should only be called when all the async actions + // finished, so the count should be 10 at this point. + expect(timeout.$('.val').getText()).toEqual('10'); + expect(didWork).toBeTruthy(); // Work was done. + }); + + whenAllStable().then((didWork) => { + // whenAllStable should be called immediately since nothing is pending. + expect(didWork).toBeFalsy(); // No work was done. + browser.ignoreSynchronization = false; + }); + }); + afterEach(verifyNoBrowserErrors); });