This repository was archived by the owner on Feb 26, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 408
feat: Add a zone spec for fake async test zone. #330
Merged
+572
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
})(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if the callback returns 0 or null?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I see the wrapper below. Never mind