diff --git a/modules/angular2/src/core/testability/testability.ts b/modules/angular2/src/core/testability/testability.ts index f3ee2699aa..478f6bc740 100644 --- a/modules/angular2/src/core/testability/testability.ts +++ b/modules/angular2/src/core/testability/testability.ts @@ -3,6 +3,8 @@ import {DOM} from 'angular2/src/dom/dom_adapter'; import {Map, MapWrapper, List, ListWrapper} from 'angular2/src/facade/collection'; import {StringWrapper, isBlank, BaseException} from 'angular2/src/facade/lang'; import * as getTestabilityModule from './get_testability'; +import {NgZone} from '../zone/ng_zone'; +import {PromiseWrapper} from 'angular2/src/facade/async'; /** @@ -12,40 +14,57 @@ import * as getTestabilityModule from './get_testability'; */ @Injectable() export class Testability { - _pendingCount: number; - _callbacks: List; + _pendingCount: number = 0; + _callbacks: List = []; + _isAngularEventPending: boolean = false; - constructor() { - this._pendingCount = 0; - this._callbacks = []; + constructor(public _ngZone: NgZone) { this._watchAngularEvents(_ngZone); } + + _watchAngularEvents(_ngZone: NgZone): void { + _ngZone.overrideOnTurnStart(() => { this._isAngularEventPending = true; }); + _ngZone.overrideOnEventDone(() => { + this._isAngularEventPending = false; + this._runCallbacksIfReady(); + }, true); } - increaseCount(delta: number = 1): number { - this._pendingCount += delta; - if (this._pendingCount < 0) { - throw new BaseException('pending async requests below zero'); - } else if (this._pendingCount == 0) { - this._runCallbacks(); - } + increasePendingRequestCount(): number { + this._pendingCount += 1; return this._pendingCount; } - _runCallbacks() { - while (this._callbacks.length !== 0) { - ListWrapper.removeLast(this._callbacks)(); + decreasePendingRequestCount(): number { + this._pendingCount -= 1; + if (this._pendingCount < 0) { + throw new BaseException('pending async requests below zero'); } + this._runCallbacksIfReady(); + return this._pendingCount; } - whenStable(callback: Function) { + _runCallbacksIfReady(): void { + if (this._pendingCount != 0 || this._isAngularEventPending) { + 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())(); + } + }); + } + + whenStable(callback: Function): void { this._callbacks.push(callback); - - if (this._pendingCount === 0) { - this._runCallbacks(); - } - // TODO(juliemr) - hook into the zone api. + this._runCallbacksIfReady(); } - getPendingCount(): number { return this._pendingCount; } + getPendingRequestCount(): number { return this._pendingCount; } + + // This only accounts for ngZone, and not pending counts. Use `whenStable` to + // check for stability. + isAngularEventPending(): boolean { return this._isAngularEventPending; } findBindings(using: any, binding: string, exactMatch: boolean): List { // TODO(juliemr): implement. @@ -55,13 +74,9 @@ export class Testability { @Injectable() export class TestabilityRegistry { - _applications: Map; + _applications: Map = new Map(); - constructor() { - this._applications = new Map(); - - getTestabilityModule.GetTestability.addToWindow(this); - } + constructor() { getTestabilityModule.GetTestability.addToWindow(this); } registerApplication(token: any, testability: Testability) { this._applications.set(token, testability); diff --git a/modules/angular2/src/core/zone/ng_zone.dart b/modules/angular2/src/core/zone/ng_zone.dart index 1dcd726bf2..b78a0e9807 100644 --- a/modules/angular2/src/core/zone/ng_zone.dart +++ b/modules/angular2/src/core/zone/ng_zone.dart @@ -6,6 +6,36 @@ import 'package:stack_trace/stack_trace.dart' show Chain; typedef void ZeroArgFunction(); typedef void ErrorHandlingFn(error, stackTrace); +/** + * A `Timer` wrapper that lets you specify additional functions to call when it + * is cancelled. + */ +class WrappedTimer implements Timer { + + Timer _timer; + ZeroArgFunction _onCancelCb; + + WrappedTimer(Timer timer) { + _timer = timer; + } + + void addOnCancelCb(ZeroArgFunction onCancelCb) { + if (this._onCancelCb != null) { + throw "On cancel cb already registered"; + } + this._onCancelCb = onCancelCb; + } + + void cancel() { + if (this._onCancelCb != null) { + this._onCancelCb(); + } + _timer.cancel(); + } + + bool get isActive => _timer.isActive; +} + /** * A `Zone` wrapper that lets you schedule tasks after its private microtask queue is exhausted but * before the next "VM turn", i.e. event loop iteration. @@ -45,6 +75,8 @@ class NgZone { bool _inVmTurnDone = false; + List _pendingTimers = []; + /** * Associates with this * @@ -92,8 +124,16 @@ class NgZone { * * This hook is useful for validating application state (e.g. in a test). */ - void overrideOnEventDone(ZeroArgFunction onEventDoneFn) { + void overrideOnEventDone(ZeroArgFunction onEventDoneFn, [bool waitForAsync = false]) { _onEventDone = onEventDoneFn; + + if (waitForAsync) { + _onEventDone = () { + if (_pendingTimers.length == 0) { + onEventDoneFn(); + } + }; + } } /** @@ -224,6 +264,20 @@ class NgZone { } } + Timer _createTimer(Zone self, ZoneDelegate parent, Zone zone, Duration duration, fn()) { + WrappedTimer wrappedTimer; + var cb = () { + fn(); + _pendingTimers.remove(wrappedTimer); + }; + Timer timer = parent.createTimer(zone, duration, cb); + wrappedTimer = new WrappedTimer(timer); + wrappedTimer.addOnCancelCb(() => _pendingTimers.remove(wrappedTimer)); + + _pendingTimers.add(wrappedTimer); + return wrappedTimer; + } + Zone _createInnerZone(Zone zone, {handleUncaughtError}) { return zone.fork( specification: new ZoneSpecification( @@ -231,7 +285,8 @@ class NgZone { run: _run, runUnary: _runUnary, runBinary: _runBinary, - handleUncaughtError: handleUncaughtError), + handleUncaughtError: handleUncaughtError, + createTimer: _createTimer), zoneValues: {'_innerZone': true}); } } diff --git a/modules/angular2/src/core/zone/ng_zone.ts b/modules/angular2/src/core/zone/ng_zone.ts index 97fcf9710f..184a80b1e9 100644 --- a/modules/angular2/src/core/zone/ng_zone.ts +++ b/modules/angular2/src/core/zone/ng_zone.ts @@ -40,6 +40,8 @@ export class NgZone { _inVmTurnDone: boolean = false; + _pendingTimeouts: List = []; + /** * Associates with this * @@ -93,8 +95,17 @@ export class NgZone { * * This hook is useful for validating application state (e.g. in a test). */ - overrideOnEventDone(onEventDoneFn: Function): void { - this._onEventDone = normalizeBlank(onEventDoneFn); + overrideOnEventDone(onEventDoneFn: Function, opt_waitForAsync: boolean): void { + var normalizedOnEventDone = normalizeBlank(onEventDoneFn); + if (opt_waitForAsync) { + this._onEventDone = () => { + if (!this._pendingTimeouts.length) { + normalizedOnEventDone(); + } + }; + } else { + this._onEventDone = normalizedOnEventDone; + } } /** @@ -215,6 +226,24 @@ export class NgZone { parentScheduleMicrotask.call(this, microtask); }; }, + '$setTimeout': function(parentSetTimeout) { + return function(fn: Function, delay: number, ...args) { + var id; + var cb = function() { + fn(); + ListWrapper.remove(ngZone._pendingTimeouts, id); + }; + id = parentSetTimeout(cb, delay, args); + ngZone._pendingTimeouts.push(id); + return id; + }; + }, + '$clearTimeout': function(parentClearTimeout) { + return function(id: number) { + parentClearTimeout(id); + ListWrapper.remove(ngZone._pendingTimeouts, id); + }; + }, _innerZone: true }); } diff --git a/modules/angular2/test/core/testability/testability_spec.ts b/modules/angular2/test/core/testability/testability_spec.ts index 6d18a7a4da..4cd900b6f4 100644 --- a/modules/angular2/test/core/testability/testability_spec.ts +++ b/modules/angular2/test/core/testability/testability_spec.ts @@ -1,41 +1,212 @@ -import {describe, ddescribe, it, iit, xit, xdescribe, expect, beforeEach} from 'angular2/test_lib'; +import { + AsyncTestCompleter, + inject, + describe, + ddescribe, + it, + iit, + xit, + xdescribe, + expect, + beforeEach, + SpyObject +} from 'angular2/test_lib'; import {Testability} from 'angular2/src/core/testability/testability'; +import {NgZone} from 'angular2/src/core/zone/ng_zone'; +import {normalizeBlank} from 'angular2/src/facade/lang'; +import {PromiseWrapper} from 'angular2/src/facade/async'; +// Schedules a microtasks (using a resolved promise .then()) +function microTask(fn: Function): void { + PromiseWrapper.resolve(null).then((_) => { fn(); }); +} + +class MockNgZone extends NgZone { + _onTurnStart: () => void; + _onEventDone: () => void; + + constructor() { super({enableLongStackTrace: false}); } + + start(): void { this._onTurnStart(); } + + finish(): void { this._onEventDone(); } + + overrideOnTurnStart(onTurnStartFn: Function): void { + this._onTurnStart = normalizeBlank(onTurnStartFn); + } + + overrideOnEventDone(onEventDoneFn: Function, waitForAsync: boolean = false): void { + this._onEventDone = normalizeBlank(onEventDoneFn); + } +} export function main() { describe('Testability', () => { - var testability, executed; + var testability, execute, ngZone; beforeEach(() => { - testability = new Testability(); - executed = false; + ngZone = new MockNgZone(); + testability = new Testability(ngZone); + execute = new SpyObject().spy('execute'); }); - it('should start with a pending count of 0', - () => { expect(testability.getPendingCount()).toEqual(0); }); + describe('Pending count logic', () => { + it('should start with a pending count of 0', + () => { expect(testability.getPendingRequestCount()).toEqual(0); }); - it('should fire whenstable callbacks if pending count is 0', () => { - testability.whenStable(() => executed = true); - expect(executed).toBe(true); + it('should fire whenstable callbacks if pending count is 0', + inject([AsyncTestCompleter], (async) => { + testability.whenStable(execute); + microTask(() => { + expect(execute).toHaveBeenCalled(); + async.done(); + }); + })); + + it('should not fire whenstable callbacks synchronously if pending count is 0', () => { + testability.whenStable(execute); + expect(execute).not.toHaveBeenCalled(); + }); + + it('should not call whenstable callbacks when there are pending counts', + inject([AsyncTestCompleter], (async) => { + testability.increasePendingRequestCount(); + testability.increasePendingRequestCount(); + testability.whenStable(execute); + + microTask(() => { + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); + + microTask(() => { + expect(execute).not.toHaveBeenCalled(); + async.done(); + }); + }); + })); + + it('should fire whenstable callbacks when pending drops to 0', + inject([AsyncTestCompleter], (async) => { + testability.increasePendingRequestCount(); + testability.whenStable(execute); + + microTask(() => { + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); + + microTask(() => { + expect(execute).toHaveBeenCalled(); + async.done(); + }); + }); + })); + + it('should not fire whenstable callbacks synchronously when pending drops to 0', () => { + testability.increasePendingRequestCount(); + testability.whenStable(execute); + testability.decreasePendingRequestCount(); + + expect(execute).not.toHaveBeenCalled(); + }); }); - it('should not call whenstable callbacks when there are pending counts', () => { - testability.increaseCount(2); - testability.whenStable(() => executed = true); + describe('NgZone callback logic', () => { + it('should start being ready', + () => { expect(testability.isAngularEventPending()).toEqual(false); }); - expect(executed).toBe(false); - testability.increaseCount(-1); - expect(executed).toBe(false); - }); + it('should fire whenstable callback if event is already finished', + inject([AsyncTestCompleter], (async) => { + ngZone.start(); + ngZone.finish(); + testability.whenStable(execute); - it('should fire whenstable callbacks when pending drops to 0', () => { - testability.increaseCount(2); - testability.whenStable(() => executed = true); + microTask(() => { + expect(execute).toHaveBeenCalled(); + async.done(); + }); + })); - expect(executed).toBe(false); + it('should not fire whenstable callbacks synchronously if event is already finished', () => { + ngZone.start(); + ngZone.finish(); + testability.whenStable(execute); - testability.increaseCount(-2); - expect(executed).toBe(true); + expect(execute).not.toHaveBeenCalled(); + }); + + it('should fire whenstable callback when event finishes', + inject([AsyncTestCompleter], (async) => { + ngZone.start(); + testability.whenStable(execute); + + microTask(() => { + expect(execute).not.toHaveBeenCalled(); + ngZone.finish(); + + microTask(() => { + expect(execute).toHaveBeenCalled(); + async.done(); + }); + }); + })); + + it('should not fire whenstable callbacks synchronously when event finishes', () => { + ngZone.start(); + testability.whenStable(execute); + ngZone.finish(); + + expect(execute).not.toHaveBeenCalled(); + }); + + it('should not fire whenstable callback when event did not finish', + inject([AsyncTestCompleter], (async) => { + ngZone.start(); + testability.increasePendingRequestCount(); + testability.whenStable(execute); + + microTask(() => { + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); + + microTask(() => { + expect(execute).not.toHaveBeenCalled(); + ngZone.finish(); + + microTask(() => { + expect(execute).toHaveBeenCalled(); + async.done(); + }); + }); + }); + })); + + it('should not fire whenstable callback when there are pending counts', + inject([AsyncTestCompleter], (async) => { + ngZone.start(); + testability.increasePendingRequestCount(); + testability.increasePendingRequestCount(); + testability.whenStable(execute); + + microTask(() => { + expect(execute).not.toHaveBeenCalled(); + ngZone.finish(); + + microTask(() => { + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); + + microTask(() => { + expect(execute).not.toHaveBeenCalled(); + testability.decreasePendingRequestCount(); + + microTask(() => { + expect(execute).toHaveBeenCalled(); + async.done(); + }); + }); + }); + }); + })); }); }); } diff --git a/modules/examples/e2e_test/async/async_spec.dart b/modules/examples/e2e_test/async/async_spec.dart new file mode 100644 index 0000000000..ac74c42830 --- /dev/null +++ b/modules/examples/e2e_test/async/async_spec.dart @@ -0,0 +1,5 @@ +library examples.e2e_test.async_spec; + +main() { + +} diff --git a/modules/examples/e2e_test/async/async_spec.ts b/modules/examples/e2e_test/async/async_spec.ts new file mode 100644 index 0000000000..27d8379d85 --- /dev/null +++ b/modules/examples/e2e_test/async/async_spec.ts @@ -0,0 +1,71 @@ +import {verifyNoBrowserErrors} from 'angular2/src/test_lib/e2e_util'; + +function whenStable(rootSelector) { + // TODO(hankduan): remove this call once Protractor implements it + return browser.executeAsyncScript('var el = document.querySelector("' + rootSelector + '");' + + 'window.getAngularTestability(el).whenStable(arguments[0]);'); +}; + +describe('async', () => { + var URL = 'examples/src/async/index.html'; + + beforeEach(() => browser.get(URL)); + + it('should work with synchronous actions', () => { + var increment = $('#increment'); + increment.$('.action').click(); + + expect(increment.$('.val').getText()).toEqual('1'); + }); + + it('should wait for asynchronous actions', () => { + var timeout = $('#delayedIncrement'); + timeout.$('.action').click(); + + // At this point, the async action is still pending, so the count should + // still be 0. + expect(timeout.$('.val').getText()).toEqual('0'); + + whenStable('async-app') + .then(() => { + // whenStable should only be called when the async action finished, + // so the count should be 1 at this point. + expect(timeout.$('.val').getText()).toEqual('1'); + }); + }); + + it('should notice when asynchronous actions are cancelled', () => { + var timeout = $('#delayedIncrement'); + timeout.$('.action').click(); + + // At this point, the async action is still pending, so the count should + // still be 0. + expect(timeout.$('.val').getText()).toEqual('0'); + + timeout.$('.cancel').click(); + whenStable('async-app') + .then(() => { + // whenStable should be called since the async action is cancelled. The + // count should still be 0; + expect(timeout.$('.val').getText()).toEqual('0'); + }); + }); + + it('should wait for a series of asynchronous actions', () => { + var timeout = $('#multiDelayedIncrements'); + timeout.$('.action').click(); + + // At this point, the async action is still pending, so the count should + // still be 0. + expect(timeout.$('.val').getText()).toEqual('0'); + + whenStable('async-app') + .then(() => { + // whenStable 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'); + }); + }); + + afterEach(verifyNoBrowserErrors); +}); diff --git a/modules/examples/src/async/index.html b/modules/examples/src/async/index.html new file mode 100644 index 0000000000..48c6660e32 --- /dev/null +++ b/modules/examples/src/async/index.html @@ -0,0 +1,14 @@ + + + + Async + + + + + Loading... + + + $SCRIPTS$ + + diff --git a/modules/examples/src/async/index.ts b/modules/examples/src/async/index.ts new file mode 100644 index 0000000000..7107cda187 --- /dev/null +++ b/modules/examples/src/async/index.ts @@ -0,0 +1,96 @@ +import {NgIf, bootstrap, Component, View} from 'angular2/bootstrap'; +import {TimerWrapper} from 'angular2/src/facade/async'; + + +@Component({selector: 'async-app'}) +@View({ + template: ` +
+ {{val1}} + +
+
+ {{val2}} + + +
+
+ {{val3}} + + +
+
+ {{val4}} + + +
+ `, + directives: [NgIf] +}) +class AsyncApplication { + val1: number = 0; + val2: number = 0; + val3: number = 0; + val4: number = 0; + timeoutId = null; + multiTimeoutId = null; + intervalId = null; + + increment(): void { this.val1++; }; + + delayedIncrement(): void { + this.cancelDelayedIncrement(); + this.timeoutId = TimerWrapper.setTimeout(() => { + this.val2++; + this.timeoutId = null; + }, 2000); + }; + + multiDelayedIncrements(i: number): void { + this.cancelMultiDelayedIncrements(); + + var self = this; + function helper(_i) { + if (_i <= 0) { + self.multiTimeoutId = null; + return; + } + + self.multiTimeoutId = TimerWrapper.setTimeout(() => { + self.val3++; + helper(_i - 1); + }, 500); + } + helper(i); + }; + + periodicIncrement(): void { + this.cancelPeriodicIncrement(); + this.intervalId = TimerWrapper.setInterval(() => { this.val4++; }, 2000) + }; + + cancelDelayedIncrement(): void { + if (this.timeoutId != null) { + TimerWrapper.clearTimeout(this.timeoutId); + this.timeoutId = null; + } + }; + + cancelMultiDelayedIncrements(): void { + if (this.multiTimeoutId != null) { + TimerWrapper.clearTimeout(this.multiTimeoutId); + this.multiTimeoutId = null; + } + }; + + cancelPeriodicIncrement(): void { + if (this.intervalId != null) { + TimerWrapper.clearInterval(this.intervalId); + this.intervalId = null; + } + }; +} + +export function main() { + bootstrap(AsyncApplication); +} diff --git a/tools/broccoli/trees/browser_tree.ts b/tools/broccoli/trees/browser_tree.ts index 5f98e34d0e..e0fe47a72d 100644 --- a/tools/broccoli/trees/browser_tree.ts +++ b/tools/broccoli/trees/browser_tree.ts @@ -53,6 +53,7 @@ const kServedPaths = [ 'examples/src/sourcemap', 'examples/src/todo', 'examples/src/zippy_component', + 'examples/src/async', 'examples/src/material/button', 'examples/src/material/checkbox', 'examples/src/material/dialog',