From 6687ceef0892da6f4b3c2cff8c009f4373786b3d Mon Sep 17 00:00:00 2001 From: Vikram Subramanian Date: Tue, 22 Mar 2016 17:22:19 -0700 Subject: [PATCH] feat: Add a zone spec for fake async test zone. --- gulpfile.js | 5 + lib/zone-spec/fake-async-test.ts | 256 ++++++++++++++++++++ test/common_tests.ts | 1 + test/zone-spec/fake-async-test.spec.ts | 310 +++++++++++++++++++++++++ 4 files changed, 572 insertions(+) create mode 100644 lib/zone-spec/fake-async-test.ts create mode 100644 test/zone-spec/fake-async-test.spec.ts diff --git a/gulpfile.js b/gulpfile.js index 6802e733f..e455b7c68 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -109,6 +109,10 @@ gulp.task('build/async-test.js', function(cb) { return generateBrowserScript('./lib/zone-spec/async-test.ts', 'async-test.js', false, cb); }); +gulp.task('build/fake-async-test.js', function(cb) { + return generateBrowserScript('./lib/zone-spec/fake-async-test.ts', 'fake-async-test.js', false, cb); +}); + gulp.task('build/sync-test.js', function(cb) { return generateBrowserScript('./lib/zone-spec/sync-test.ts', 'sync-test.js', false, cb); }); @@ -125,6 +129,7 @@ gulp.task('build', [ 'build/wtf.js', 'build/wtf.min.js', 'build/async-test.js', + 'build/fake-async-test.js', 'build/sync-test.js' ]); diff --git a/lib/zone-spec/fake-async-test.ts b/lib/zone-spec/fake-async-test.ts new file mode 100644 index 000000000..f6b3f829f --- /dev/null +++ b/lib/zone-spec/fake-async-test.ts @@ -0,0 +1,256 @@ +(function() { + interface ScheduledFunction { + endTime: number; + id: number, + func: Function; + args: any[]; + delay: number; + } + + class Scheduler { + // Next scheduler id. + public nextId: number = 0; + + // Scheduler queue with the tuple of end time and callback function - sorted by end time. + private _schedulerQueue: ScheduledFunction[] = []; + // Current simulated time in millis. + private _currentTime: number = 0; + + constructor() {} + + scheduleFunction(cb: Function, delay: number, args: any[] = [], id: number = -1) : number { + let currentId: number = id < 0 ? this.nextId++ : id; + let endTime = this._currentTime + delay; + + // Insert so that scheduler queue remains sorted by end time. + let newEntry: ScheduledFunction = { + endTime: endTime, + id: currentId, + func: cb, + args: args, + delay: delay + } + let i = 0; + for (; i < this._schedulerQueue.length; i++) { + let currentEntry = this._schedulerQueue[i]; + if (newEntry.endTime < currentEntry.endTime) { + break; + } + } + this._schedulerQueue.splice(i, 0, newEntry); + return currentId; + } + + removeScheduledFunctionWithId(id: number): void { + for (let i = 0; i < this._schedulerQueue.length; i++) { + if (this._schedulerQueue[i].id == id) { + this._schedulerQueue.splice(i, 1); + break; + } + } + } + + tick(millis: number = 0): void { + this._currentTime += millis; + while (this._schedulerQueue.length > 0) { + let current = this._schedulerQueue[0]; + if (this._currentTime < 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 retval = current.func.apply(global, current.args); + if (!retval) { + // Uncaught exception in the current scheduled function. Stop processing the queue. + break; + } + } + } + } + } + + class FakeAsyncTestZoneSpec implements ZoneSpec { + static assertInZone(): void { + if (Zone.current.get('FakeAsyncTestZoneSpec') == null) { + throw new Error('The code should be running in the fakeAsync zone to call this function'); + } + } + + private _scheduler: Scheduler = new Scheduler(); + private _microtasks: Function[] = []; + private _lastError: Error = null; + + pendingPeriodicTimers: number[] = []; + pendingTimers: number[] = []; + + constructor(namePrefix: string) { + this.name = 'fakeAsyncTestZone for ' + namePrefix; + } + + private _fnAndFlush(fn: Function, + completers: {onSuccess?: Function, onError?: Function}): Function { + return (...args): boolean => { + fn.apply(global, args); + + if (this._lastError === null) { // Success + if (completers.onSuccess != null) { + completers.onSuccess.apply(global); + } + // Flush microtasks only on success. + this.flushMicrotasks(); + } else { // Failure + if (completers.onError != null) { + completers.onError.apply(global); + } + } + // Return true if there were no errors, false otherwise. + return this._lastError === null; + } + } + + private static _removeTimer(timers: number[], id:number): void { + let index = timers.indexOf(id); + if (index > -1) { + timers.splice(index, 1); + } + } + + private _dequeueTimer(id: number): Function { + return () => { + FakeAsyncTestZoneSpec._removeTimer(this.pendingTimers, id); + }; + } + + private _requeuePeriodicTimer( + fn: Function, interval: number, args: any[], id: number): Function { + return () => { + // Requeue the timer callback if it's not been canceled. + if (this.pendingPeriodicTimers.indexOf(id) !== -1) { + this._scheduler.scheduleFunction(fn, interval, args, id); + } + } + } + + private _dequeuePeriodicTimer(id: number): Function { + return () => { + FakeAsyncTestZoneSpec._removeTimer(this.pendingPeriodicTimers, id); + }; + } + + private _setTimeout(fn: Function, delay: number, args: any[]): number { + let removeTimerFn = this._dequeueTimer(this._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); + this.pendingTimers.push(id); + return id; + } + + private _clearTimeout(id: number): void { + FakeAsyncTestZoneSpec._removeTimer(this.pendingTimers, id); + this._scheduler.removeScheduledFunctionWithId(id); + } + + private _setInterval(fn: Function, interval: number, ...args): number { + let id = this._scheduler.nextId; + let completers = {onSuccess: null, onError: this._dequeuePeriodicTimer(id)}; + let cb = this._fnAndFlush(fn, completers); + + // Use the callback created above to requeue on success. + 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); + this.pendingPeriodicTimers.push(id); + return id; + } + + private _clearInterval(id: number): void { + FakeAsyncTestZoneSpec._removeTimer(this.pendingPeriodicTimers, id); + this._scheduler.removeScheduledFunctionWithId(id); + } + + private _resetLastErrorAndThrow(): void { + let error = this._lastError; + this._lastError = null; + throw error; + } + + tick(millis: number = 0): void { + FakeAsyncTestZoneSpec.assertInZone(); + this.flushMicrotasks(); + this._scheduler.tick(millis); + if (this._lastError !== null) { + this._resetLastErrorAndThrow(); + } + } + + flushMicrotasks(): void { + FakeAsyncTestZoneSpec.assertInZone(); + while (this._microtasks.length > 0) { + let microtask = this._microtasks.shift(); + microtask(); + if (this._lastError !== null) { + // If there is an error stop processing the microtask queue and rethrow the error. + this._resetLastErrorAndThrow(); + } + } + } + + // ZoneSpec implementation below. + + name: string; + + properties: { [key: string]: any } = { 'FakeAsyncTestZoneSpec': this }; + + onScheduleTask(delegate: ZoneDelegate, current: Zone, target: Zone, task: Task): Task { + switch (task.type) { + case 'microTask': + this._microtasks.push(task.invoke); + break; + case 'macroTask': + switch (task.source) { + case 'setTimeout': + task.data['handleId'] = + this._setTimeout(task.invoke, task.data['delay'], task.data['args']); + break; + case 'setInterval': + task.data['handleId'] = + this._setInterval(task.invoke, task.data['delay'], task.data['args']); + break; + case 'XMLHttpRequest.send': + throw new Error('Cannot make XHRs from within a fake async test.'); + default: + task = delegate.scheduleTask(target, task); + } + break; + case 'eventTask': + task = delegate.scheduleTask(target, task); + break; + } + return task; + } + + onCancelTask(delegate: ZoneDelegate, current: Zone, target: Zone, task: Task): any { + switch (task.source) { + case 'setTimeout': + return this._clearTimeout(task.data['handleId']); + case 'setInterval': + return this._clearInterval(task.data['handleId']); + default: + return delegate.cancelTask(target, task); + } + } + + onHandleError(parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, + error: any): boolean { + this._lastError = error; + return false; // Don't propagate error to parent zone. + } + } + + // Export the class so that new instances can be created with proper + // constructor params. + Zone['FakeAsyncTestZoneSpec'] = FakeAsyncTestZoneSpec; +})(); diff --git a/test/common_tests.ts b/test/common_tests.ts index 50b3f80c7..f04327403 100644 --- a/test/common_tests.ts +++ b/test/common_tests.ts @@ -7,3 +7,4 @@ import './common/setTimeout.spec'; import './zone-spec/long-stack-trace-zone.spec'; import './zone-spec/async-test.spec'; import './zone-spec/sync-test.spec'; +import './zone-spec/fake-async-test.spec'; diff --git a/test/zone-spec/fake-async-test.spec.ts b/test/zone-spec/fake-async-test.spec.ts new file mode 100644 index 000000000..29f1b5019 --- /dev/null +++ b/test/zone-spec/fake-async-test.spec.ts @@ -0,0 +1,310 @@ +import '../../lib/zone-spec/fake-async-test'; +import {ifEnvSupports} from '../test-util'; + +describe('FakeAsyncTestZoneSpec', () => { + let FakeAsyncTestZoneSpec = Zone['FakeAsyncTestZoneSpec']; + let testZoneSpec; + let fakeAsyncTestZone; + + beforeEach(() => { + testZoneSpec = new FakeAsyncTestZoneSpec('name'); + fakeAsyncTestZone = Zone.current.fork(testZoneSpec); + }); + + it('sets the FakeAsyncTestZoneSpec property', () => { + fakeAsyncTestZone.run(() => { + expect(Zone.current.get('FakeAsyncTestZoneSpec')).toEqual(testZoneSpec); + }); + }); + + describe('synchronous code', () => { + it('should run', () => { + let ran = false; + fakeAsyncTestZone.run(() => { ran = true; }); + + expect(ran).toEqual(true); + }); + + it('should throw the error in the code', () => { + expect(() => { + fakeAsyncTestZone.run(() => { throw new Error('sync'); }); + }).toThrowError('sync'); + }); + }); + + describe('asynchronous code', () => { + it('should run', () => { + fakeAsyncTestZone.run(() => { + let thenRan = false; + Promise.resolve(null).then((_) => { thenRan = true; }); + + expect(thenRan).toEqual(false); + + testZoneSpec.flushMicrotasks(); + expect(thenRan).toEqual(true); + }); + }); + + it('should rethrow the exception on flushMicroTasks for error thrown in Promise callback', + () => { + fakeAsyncTestZone.run(() => { + Promise.resolve(null).then((_) => { throw new Error('async'); }); + expect(() => { testZoneSpec.flushMicrotasks(); }) + .toThrowError('Uncaught (in promise): Error: async'); + }); + }); + + it('should run chained thens', () => { + fakeAsyncTestZone.run(() => { + let log = []; + + Promise.resolve(null).then((_) => log.push(1)).then((_) => log.push(2)); + + expect(log).toEqual([]); + + testZoneSpec.flushMicrotasks(); + expect(log).toEqual([1, 2]); + }); + }); + + it('should run Promise created in Promise', () => { + fakeAsyncTestZone.run(() => { + let log = []; + + Promise.resolve(null).then((_) => { + log.push(1); + Promise.resolve(null).then((_) => log.push(2)); + }); + + expect(log).toEqual([]); + + testZoneSpec.flushMicrotasks(); + expect(log).toEqual([1, 2]); + }); + }); + }); + + describe('timers', () => { + it('should run queued zero duration timer on zero tick', () => { + fakeAsyncTestZone.run(() => { + let ran = false; + setTimeout(() => { ran = true }, 0); + + expect(ran).toEqual(false); + + testZoneSpec.tick(); + expect(ran).toEqual(true); + }); + }); + + it('should run queued timer after sufficient clock ticks', () => { + fakeAsyncTestZone.run(() => { + let ran = false; + setTimeout(() => { ran = true; }, 10); + + testZoneSpec.tick(6); + expect(ran).toEqual(false); + + testZoneSpec.tick(4); + expect(ran).toEqual(true); + }); + }); + + it('should run queued timer only once', () => { + fakeAsyncTestZone.run(() => { + let cycles = 0; + setTimeout(() => { cycles++; }, 10); + + testZoneSpec.tick(10); + expect(cycles).toEqual(1); + + testZoneSpec.tick(10); + expect(cycles).toEqual(1); + + testZoneSpec.tick(10); + expect(cycles).toEqual(1); + }); + expect(testZoneSpec.pendingTimers.length).toBe(0); + }); + + it('should not run cancelled timer', () => { + fakeAsyncTestZone.run(() => { + let ran = false; + let id = setTimeout(() => { ran = true; }, 10); + clearTimeout(id); + + testZoneSpec.tick(10); + expect(ran).toEqual(false); + }); + }); + + it('should run periodic timers', () => { + fakeAsyncTestZone.run(() => { + let cycles = 0; + let id = setInterval(() => { cycles++; }, 10); + + testZoneSpec.tick(10); + expect(cycles).toEqual(1); + + testZoneSpec.tick(10); + expect(cycles).toEqual(2); + + testZoneSpec.tick(10); + expect(cycles).toEqual(3); + }); + }); + + it('should not run cancelled periodic timer', () => { + fakeAsyncTestZone.run(() => { + let ran = false; + let id = setInterval(() => { ran = true; }, 10); + + testZoneSpec.tick(10); + expect(ran).toEqual(true); + + ran = false; + clearInterval(id); + testZoneSpec.tick(10); + expect(ran).toEqual(false); + }); + }); + + it('should be able to cancel periodic timers from a callback', () => { + fakeAsyncTestZone.run(() => { + let cycles = 0; + let id; + + id = setInterval(() => { + cycles++; + clearInterval(id); + }, 10); + + testZoneSpec.tick(10); + expect(cycles).toEqual(1); + + testZoneSpec.tick(10); + expect(cycles).toEqual(1); + }); + }); + + it('should process microtasks before timers', () => { + fakeAsyncTestZone.run(() => { + let log = []; + + Promise.resolve(null).then((_) => log.push('microtask')); + + setTimeout(() => log.push('timer'), 9); + + setInterval(() => log.push('periodic timer'), 10); + + expect(log).toEqual([]); + + testZoneSpec.tick(10); + expect(log).toEqual(['microtask', 'timer', 'periodic timer']); + }); + }); + + it('should process micro-tasks created in timers before next timers', () => { + fakeAsyncTestZone.run(() => { + let log = []; + + Promise.resolve(null).then((_) => log.push('microtask')); + + setTimeout(() => { + log.push('timer'); + Promise.resolve(null).then((_) => log.push('t microtask')); + }, 9); + + let id = setInterval(() => { + log.push('periodic timer'); + Promise.resolve(null).then((_) => log.push('pt microtask')); + }, 10); + + testZoneSpec.tick(10); + expect(log) + .toEqual(['microtask', 'timer', 't microtask', 'periodic timer', 'pt microtask']); + + testZoneSpec.tick(10); + expect(log) + .toEqual( + ['microtask', 'timer', 't microtask', 'periodic timer', 'pt microtask', 'periodic timer', + 'pt microtask']); + }); + }); + + it('should throw the exception from tick for error thrown in timer callback', () => { + fakeAsyncTestZone.run(() => { + setTimeout(() => { throw new Error('timer'); }, 10); + expect(() => { testZoneSpec.tick(10); }).toThrowError('timer'); + }); + // There should be no pending timers after the error in timer callback. + expect(testZoneSpec.pendingTimers.length).toBe(0); + }); + + it('should throw the exception from tick for error thrown in periodic timer callback', () => { + fakeAsyncTestZone.run(() => { + let count = 0; + setInterval(() => { + count++; + throw new Error(count.toString()); + }, 10); + + expect(() => { testZoneSpec.tick(10); }).toThrowError('1'); + + // Periodic timer is cancelled on first error. + expect(count).toBe(1); + testZoneSpec.tick(10); + expect(count).toBe(1); + }); + // Periodic timer is removed from pending queue on error. + expect(testZoneSpec.pendingPeriodicTimers.length).toBe(0); + }); + }); + + it('should be able to resume processing timer callbacks after handling an error', () => { + fakeAsyncTestZone.run(() => { + let ran = false; + setTimeout(() => { throw new Error('timer'); }, 10); + setTimeout(() => {ran = true; }, 10); + expect(() => { testZoneSpec.tick(10); }).toThrowError('timer'); + expect(ran).toBe(false); + + // Restart timer queue processing. + testZoneSpec.tick(0); + expect(ran).toBe(true); + }); + // There should be no pending timers after the error in timer callback. + expect(testZoneSpec.pendingTimers.length).toBe(0); + }); + + describe('outside of FakeAsync Zone', () => { + it('calling flushMicrotasks should throw exception', () => { + expect(() => { testZoneSpec.flushMicrotasks(); }) + .toThrowError('The code should be running in the fakeAsync zone to call this function'); + }); + it('calling tick should throw exception', () => { + expect(() => { testZoneSpec.tick(); }) + .toThrowError('The code should be running in the fakeAsync zone to call this function'); + }); + }); + + describe('XHRs', ifEnvSupports('XMLHttpRequest', () => { + it('should throw an exception if an XHR is initiated in the zone', () => { + expect(() => { + fakeAsyncTestZone.run(() => { + let finished = false; + let req = new XMLHttpRequest(); + + req.onreadystatechange = () => { + if (req.readyState === XMLHttpRequest.DONE) { + finished = true; + } + }; + + req.open('GET', '/', true); + req.send(); + }); + }).toThrowError('Cannot make XHRs from within a fake async test.'); + }); + })); +});