diff --git a/aio/content/examples/testing/src/app/demo/async-helper.spec.ts b/aio/content/examples/testing/src/app/demo/async-helper.spec.ts index ca0239408d..8f6d340ffb 100644 --- a/aio/content/examples/testing/src/app/demo/async-helper.spec.ts +++ b/aio/content/examples/testing/src/app/demo/async-helper.spec.ts @@ -25,21 +25,21 @@ describe('Angular async helper', () => { async(() => { setTimeout(() => { actuallyDone = true; }, 0); })); it('should run async test with task', async(() => { - const id = setInterval(() => { - actuallyDone = true; - clearInterval(id); - }, 100); - })); + const id = setInterval(() => { + actuallyDone = true; + clearInterval(id); + }, 100); + })); it('should run async test with successful promise', async(() => { - const p = new Promise(resolve => { setTimeout(resolve, 10); }); - p.then(() => { actuallyDone = true; }); - })); + const p = new Promise(resolve => { setTimeout(resolve, 10); }); + p.then(() => { actuallyDone = true; }); + })); it('should run async test with failed promise', async(() => { - const p = new Promise((resolve, reject) => { setTimeout(reject, 10); }); - p.catch(() => { actuallyDone = true; }); - })); + const p = new Promise((resolve, reject) => { setTimeout(reject, 10); }); + p.catch(() => { actuallyDone = true; }); + })); // Use done. Can also use async or fakeAsync. it('should run async test with successful delayed Observable', (done: DoneFn) => { @@ -48,56 +48,84 @@ describe('Angular async helper', () => { }); it('should run async test with successful delayed Observable', async(() => { - const source = of (true).pipe(delay(10)); - source.subscribe(val => actuallyDone = true, err => fail(err)); - })); + const source = of (true).pipe(delay(10)); + source.subscribe(val => actuallyDone = true, err => fail(err)); + })); it('should run async test with successful delayed Observable', fakeAsync(() => { - const source = of (true).pipe(delay(10)); - source.subscribe(val => actuallyDone = true, err => fail(err)); + const source = of (true).pipe(delay(10)); + source.subscribe(val => actuallyDone = true, err => fail(err)); - tick(10); - })); + tick(10); + })); }); describe('fakeAsync', () => { // #docregion fake-async-test-tick it('should run timeout callback with delay after call tick with millis', fakeAsync(() => { - let called = false; - setTimeout(() => { called = true; }, 100); - tick(100); - expect(called).toBe(true); - })); + let called = false; + setTimeout(() => { called = true; }, 100); + tick(100); + expect(called).toBe(true); + })); // #enddocregion fake-async-test-tick + // #docregion fake-async-test-tick-new-macro-task-sync + it('should run new macro task callback with delay after call tick with millis', + fakeAsync(() => { + function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } + const callback = jasmine.createSpy('callback'); + nestedTimer(callback); + expect(callback).not.toHaveBeenCalled(); + tick(0); + // the nested timeout will also be triggered + expect(callback).toHaveBeenCalled(); + })); + // #enddocregion fake-async-test-tick-new-macro-task-sync + + // #docregion fake-async-test-tick-new-macro-task-async + it('should not run new macro task callback with delay after call tick with millis', + fakeAsync(() => { + function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } + const callback = jasmine.createSpy('callback'); + nestedTimer(callback); + expect(callback).not.toHaveBeenCalled(); + tick(0, {processNewMacroTasksSynchronously: false}); + // the nested timeout will not be triggered + expect(callback).not.toHaveBeenCalled(); + tick(0); + expect(callback).toHaveBeenCalled(); + })); + // #enddocregion fake-async-test-tick-new-macro-task-async + // #docregion fake-async-test-date it('should get Date diff correctly in fakeAsync', fakeAsync(() => { - const start = Date.now(); - tick(100); - const end = Date.now(); - expect(end - start).toBe(100); - })); + const start = Date.now(); + tick(100); + const end = Date.now(); + expect(end - start).toBe(100); + })); // #enddocregion fake-async-test-date // #docregion fake-async-test-rxjs it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => { - // need to add `import 'zone.js/dist/zone-patch-rxjs-fake-async' - // to patch rxjs scheduler - let result = null; - of ('hello').pipe(delay(1000)).subscribe(v => { result = v; }); - expect(result).toBeNull(); - tick(1000); - expect(result).toBe('hello'); + // need to add `import 'zone.js/dist/zone-patch-rxjs-fake-async' + // to patch rxjs scheduler + let result = null; + of ('hello').pipe(delay(1000)).subscribe(v => { result = v; }); + expect(result).toBeNull(); + tick(1000); + expect(result).toBe('hello'); - const start = new Date().getTime(); - let dateDiff = 0; - interval(1000).pipe(take(2)).subscribe(() => dateDiff = (new Date().getTime() - start)); + const start = new Date().getTime(); + let dateDiff = 0; + interval(1000).pipe(take(2)).subscribe(() => dateDiff = (new Date().getTime() - start)); - tick(1000); - expect(dateDiff).toBe(1000); - tick(1000); - expect(dateDiff).toBe(2000); - })); + tick(1000); + expect(dateDiff).toBe(1000); + tick(1000); + expect(dateDiff).toBe(2000); + })); // #enddocregion fake-async-test-rxjs }); diff --git a/aio/content/guide/testing.md b/aio/content/guide/testing.md index 3aacef659e..0eeb798aed 100644 --- a/aio/content/guide/testing.md +++ b/aio/content/guide/testing.md @@ -1266,7 +1266,8 @@ You do have to call [tick()](api/core/testing/tick) to advance the (virtual) clo Calling [tick()](api/core/testing/tick) simulates the passage of time until all pending asynchronous activities finish. In this case, it waits for the error handler's `setTimeout()`. -The [tick()](api/core/testing/tick) function accepts milliseconds as a parameter (defaults to 0 if not provided). The parameter represents how much the virtual clock advances. For example, if you have a `setTimeout(fn, 100)` in a `fakeAsync()` test, you need to use tick(100) to trigger the fn callback. +The [tick()](api/core/testing/tick) function accepts milliseconds and tickOptions as parameters, the millisecond (defaults to 0 if not provided) parameter represents how much the virtual clock advances. For example, if you have a `setTimeout(fn, 100)` in a `fakeAsync()` test, you need to use tick(100) to trigger the fn callback. The tickOptions is an optional parameter with a property called processNewMacroTasksSynchronously (defaults is true) represents whether to invoke +new generated macro tasks when ticking. + + +In this example, we have a new macro task (nested setTimeout), by default, when we `tick`, the setTimeout `outside` and `nested` will both be triggered. + + + + +And in some case, we don't want to trigger the new maco task when ticking, we can use `tick(milliseconds, {processNewMacroTasksSynchronously: false})` to not invoke new maco task. + #### Comparing dates inside fakeAsync() `fakeAsync()` simulates passage of time, which allows you to calculate the difference between dates inside `fakeAsync()`. diff --git a/packages/core/test/fake_async_spec.ts b/packages/core/test/fake_async_spec.ts index 036cabef96..ddf44dfa29 100644 --- a/packages/core/test/fake_async_spec.ts +++ b/packages/core/test/fake_async_spec.ts @@ -127,6 +127,31 @@ const ProxyZoneSpec: {assertPresent: () => void} = (Zone as any)['ProxyZoneSpec' expect(ran).toEqual(true); })); + it('should run new macro tasks created by timer callback', fakeAsync(() => { + function nestedTimer(callback: () => any): void { + setTimeout(() => setTimeout(() => callback())); + } + const callback = jasmine.createSpy('callback'); + nestedTimer(callback); + expect(callback).not.toHaveBeenCalled(); + tick(0); + expect(callback).toHaveBeenCalled(); + })); + + it('should not queue nested timer on tick with processNewMacroTasksSynchronously=false', + fakeAsync(() => { + function nestedTimer(callback: () => any): void { + setTimeout(() => setTimeout(() => callback())); + } + const callback = jasmine.createSpy('callback'); + nestedTimer(callback); + expect(callback).not.toHaveBeenCalled(); + tick(0, {processNewMacroTasksSynchronously: false}); + expect(callback).not.toHaveBeenCalled(); + flush(); + expect(callback).toHaveBeenCalled(); + })); + it('should run queued timer only once', fakeAsync(() => { let cycles = 0; setTimeout(() => { cycles++; }, 10); diff --git a/packages/core/testing/src/fake_async.ts b/packages/core/testing/src/fake_async.ts index 1f84eabdc0..f515987b59 100644 --- a/packages/core/testing/src/fake_async.ts +++ b/packages/core/testing/src/fake_async.ts @@ -62,13 +62,54 @@ export function fakeAsync(fn: Function): (...args: any[]) => any { * * {@example core/testing/ts/fake_async.ts region='basic'} * + * @param millis, the number of millisecond to advance the virtual timer + * @param tickOptions, the options of tick with a flag called + * processNewMacroTasksSynchronously, whether to invoke the new macroTasks, by default is + * false, means the new macroTasks will be invoked + * + * For example, + * + * it ('test with nested setTimeout', fakeAsync(() => { + * let nestedTimeoutInvoked = false; + * function funcWithNestedTimeout() { + * setTimeout(() => { + * nestedTimeoutInvoked = true; + * }); + * }; + * setTimeout(funcWithNestedTimeout); + * tick(); + * expect(nestedTimeoutInvoked).toBe(true); + * })); + * + * in this case, we have a nested timeout (new macroTask), when we tick, both the + * funcWithNestedTimeout and the nested timeout both will be invoked. + * + * it ('test with nested setTimeout', fakeAsync(() => { + * let nestedTimeoutInvoked = false; + * function funcWithNestedTimeout() { + * setTimeout(() => { + * nestedTimeoutInvoked = true; + * }); + * }; + * setTimeout(funcWithNestedTimeout); + * tick(0, {processNewMacroTasksSynchronously: false}); + * expect(nestedTimeoutInvoked).toBe(false); + * })); + * + * if we pass the tickOptions with processNewMacroTasksSynchronously to be false, the nested timeout + * will not be invoked. + * + * * @publicApi */ -export function tick(millis: number = 0): void { +export function tick( + millis: number = 0, tickOptions: {processNewMacroTasksSynchronously: boolean} = { + processNewMacroTasksSynchronously: true + }): void { if (fakeAsyncTestModule) { - return fakeAsyncTestModule.tick(millis); + return fakeAsyncTestModule.tick(millis, tickOptions); } else { - return tickFallback(millis); + return tickFallback(millis, tickOptions); } } diff --git a/packages/core/testing/src/fake_async_fallback.ts b/packages/core/testing/src/fake_async_fallback.ts index dd08df64ef..0b15a9533e 100644 --- a/packages/core/testing/src/fake_async_fallback.ts +++ b/packages/core/testing/src/fake_async_fallback.ts @@ -118,8 +118,11 @@ function _getFakeAsyncZoneSpec(): any { * * @publicApi */ -export function tickFallback(millis: number = 0): void { - _getFakeAsyncZoneSpec().tick(millis); +export function tickFallback( + millis: number = 0, tickOptions: {processNewMacroTasksSynchronously: boolean} = { + processNewMacroTasksSynchronously: true + }): void { + _getFakeAsyncZoneSpec().tick(millis, null, tickOptions); } /** diff --git a/packages/zone.js/lib/testing/fake-async.ts b/packages/zone.js/lib/testing/fake-async.ts index 1c0907a232..d688705cd5 100644 --- a/packages/zone.js/lib/testing/fake-async.ts +++ b/packages/zone.js/lib/testing/fake-async.ts @@ -117,7 +117,9 @@ Zone.__load_patch('fakeasync', (global: any, Zone: ZoneType, api: _ZonePrivate) * * @experimental */ - function tick(millis: number = 0): void { _getFakeAsyncZoneSpec().tick(millis); } + function tick(millis: number = 0, ignoreNestedTimeout = false): void { + _getFakeAsyncZoneSpec().tick(millis, null, ignoreNestedTimeout); + } /** * Simulates the asynchronous passage of time for the timers in the fakeAsync zone by diff --git a/packages/zone.js/lib/zone-spec/fake-async-test.ts b/packages/zone.js/lib/zone-spec/fake-async-test.ts index 9a55158f32..625b9a7763 100644 --- a/packages/zone.js/lib/zone-spec/fake-async-test.ts +++ b/packages/zone.js/lib/zone-spec/fake-async-test.ts @@ -72,6 +72,8 @@ private _currentTime: number = 0; // Current real time in millis. private _currentRealTime: number = OriginalDate.now(); + // track requeuePeriodicTimer + private _currentTickRequeuePeriodicEntries: any[] = []; constructor() {} @@ -81,10 +83,24 @@ setCurrentRealTime(realTime: number) { this._currentRealTime = realTime; } - scheduleFunction( - cb: Function, delay: number, args: any[] = [], isPeriodic: boolean = false, - isRequestAnimationFrame: boolean = false, id: number = -1): number { - let currentId: number = id < 0 ? Scheduler.nextId++ : id; + scheduleFunction(cb: Function, delay: number, options?: { + args?: any[], + isPeriodic?: boolean, + isRequestAnimationFrame?: boolean, + id?: number, + isRequeuePeriodic?: boolean + }): number { + options = { + ...{ + args: [], + isPeriodic: false, + isRequestAnimationFrame: false, + id: -1, + isRequeuePeriodic: false + }, + ...options + }; + let currentId = options.id ! < 0 ? Scheduler.nextId++ : options.id !; let endTime = this._currentTime + delay; // Insert so that scheduler queue remains sorted by end time. @@ -92,11 +108,14 @@ endTime: endTime, id: currentId, func: cb, - args: args, + args: options.args !, delay: delay, - isPeriodic: isPeriodic, - isRequestAnimationFrame: isRequestAnimationFrame + isPeriodic: options.isPeriodic !, + isRequestAnimationFrame: options.isRequestAnimationFrame ! }; + if (options.isRequeuePeriodic !) { + this._currentTickRequeuePeriodicEntries.push(newEntry); + } let i = 0; for (; i < this._schedulerQueue.length; i++) { let currentEntry = this._schedulerQueue[i]; @@ -117,21 +136,37 @@ } } - tick(millis: number = 0, doTick?: (elapsed: number) => void): void { + tick(millis: number = 0, doTick?: (elapsed: number) => void, tickOptions: { + processNewMacroTasksSynchronously: boolean + } = {processNewMacroTasksSynchronously: true}): void { let finalTime = this._currentTime + millis; let lastCurrentTime = 0; - if (this._schedulerQueue.length === 0 && doTick) { + // we need to copy the schedulerQueue so nested timeout + // will not be wrongly called in the current tick + // https://github.com/angular/angular/issues/33799 + const schedulerQueue = tickOptions.processNewMacroTasksSynchronously ? + this._schedulerQueue : + this._schedulerQueue.slice(); + if (schedulerQueue.length === 0 && doTick) { doTick(millis); return; } - while (this._schedulerQueue.length > 0) { - let current = this._schedulerQueue[0]; + while (schedulerQueue.length > 0) { + // clear requeueEntries before each loop + this._currentTickRequeuePeriodicEntries = []; + let current = schedulerQueue[0]; if (finalTime < current.endTime) { // Done processing the queue since it's sorted by endTime. break; } else { // Time to run scheduled function. Remove it from the head of queue. - let current = this._schedulerQueue.shift() !; + let current = schedulerQueue.shift() !; + if (!tickOptions.processNewMacroTasksSynchronously) { + const idx = this._schedulerQueue.indexOf(current); + if (idx >= 0) { + this._schedulerQueue.splice(idx, 1); + } + } lastCurrentTime = this._currentTime; this._currentTime = current.endTime; if (doTick) { @@ -143,6 +178,21 @@ // Uncaught exception in the current scheduled function. Stop processing the queue. break; } + + // check is there any requeue periodic entry is added in + // current loop, if there is, we need to add to current loop + if (!tickOptions.processNewMacroTasksSynchronously) { + this._currentTickRequeuePeriodicEntries.forEach(newEntry => { + let i = 0; + for (; i < schedulerQueue.length; i++) { + const currentEntry = schedulerQueue[i]; + if (newEntry.endTime < currentEntry.endTime) { + break; + } + } + schedulerQueue.splice(i, 0, newEntry); + }); + } } } lastCurrentTime = this._currentTime; @@ -274,7 +324,8 @@ return () => { // Requeue the timer callback if it's not been canceled. if (this.pendingPeriodicTimers.indexOf(id) !== -1) { - this._scheduler.scheduleFunction(fn, interval, args, true, false, id); + this._scheduler.scheduleFunction( + fn, interval, {args, isPeriodic: true, id, isRequeuePeriodic: true}); } }; } @@ -287,7 +338,8 @@ let removeTimerFn = this._dequeueTimer(Scheduler.nextId); // Queue the callback and dequeue the timer on success and error. let cb = this._fnAndFlush(fn, {onSuccess: removeTimerFn, onError: removeTimerFn}); - let id = this._scheduler.scheduleFunction(cb, delay, args, false, !isTimer); + let id = + this._scheduler.scheduleFunction(cb, delay, {args, isRequestAnimationFrame: !isTimer}); if (isTimer) { this.pendingTimers.push(id); } @@ -308,7 +360,7 @@ completers.onSuccess = this._requeuePeriodicTimer(cb, interval, args, id); // Queue the callback and dequeue the periodic timer only on error. - this._scheduler.scheduleFunction(cb, interval, args, true); + this._scheduler.scheduleFunction(cb, interval, {args, isPeriodic: true}); this.pendingPeriodicTimers.push(id); return id; } @@ -380,10 +432,12 @@ FakeAsyncTestZoneSpec.resetDate(); } - tick(millis: number = 0, doTick?: (elapsed: number) => void): void { + tick(millis: number = 0, doTick?: (elapsed: number) => void, tickOptions: { + processNewMacroTasksSynchronously: boolean + } = {processNewMacroTasksSynchronously: true}): void { FakeAsyncTestZoneSpec.assertInZone(); this.flushMicrotasks(); - this._scheduler.tick(millis, doTick); + this._scheduler.tick(millis, doTick, tickOptions); if (this._lastError !== null) { this._resetLastErrorAndThrow(); } diff --git a/packages/zone.js/test/zone-spec/fake-async-test.spec.ts b/packages/zone.js/test/zone-spec/fake-async-test.spec.ts index f538f6af88..6c7b82ed68 100644 --- a/packages/zone.js/test/zone-spec/fake-async-test.spec.ts +++ b/packages/zone.js/test/zone-spec/fake-async-test.spec.ts @@ -145,6 +145,22 @@ describe('FakeAsyncTestZoneSpec', () => { }); })); + it('should not queue new macro task on tick with processNewMacroTasksSynchronously=false', + () => { + function nestedTimer(callback: () => any): void { + setTimeout(() => setTimeout(() => callback())); + } + fakeAsyncTestZone.run(() => { + const callback = jasmine.createSpy('callback'); + nestedTimer(callback); + expect(callback).not.toHaveBeenCalled(); + testZoneSpec.tick(0, null, {processNewMacroTasksSynchronously: false}); + expect(callback).not.toHaveBeenCalled(); + testZoneSpec.flush(); + expect(callback).toHaveBeenCalled(); + }); + }); + it('should run queued timer after sufficient clock ticks', () => { fakeAsyncTestZone.run(() => { let ran = false; diff --git a/tools/public_api_guard/core/testing.d.ts b/tools/public_api_guard/core/testing.d.ts index 111156ec83..e443062de5 100644 --- a/tools/public_api_guard/core/testing.d.ts +++ b/tools/public_api_guard/core/testing.d.ts @@ -134,7 +134,9 @@ export declare type TestModuleMetadata = { aotSummaries?: () => any[]; }; -export declare function tick(millis?: number): void; +export declare function tick(millis?: number, tickOptions?: { + processNewMacroTasksSynchronously: boolean; +}): void; export declare function withModule(moduleDef: TestModuleMetadata): InjectSetupWrapper; export declare function withModule(moduleDef: TestModuleMetadata, fn: Function): () => any;