Skip to content

Commit 4e77c7f

Browse files
committed
fix(core): do not invoke jasmine done callback multiple times with waitForAsync
Currently tests written using `waitForAsync` would be prone to Jasmine warnings or errors (depending on the version) for tests incorrectly invoking asynchronous jasmine `done` callbacks multiple times. This can happen because the async test zone logic schedules the `done` callback to be called using `setTimeout`, but this could be invoked multiple times, causing multiple `done` invocations to be scheduled. Most of the issues have been resolved with #45025, but it does not solve the case of multiple tasks finished and callbacks being scheduled. Technically, the current logic is built in way that _should_ result in `_finishCallbackIfDone` and eventually the `done` callback to be invoked at maximium once. This is unfortunately not the case in some rather advanced/unexpected scenarios (like our AngularJS upgrade tests) where the scenario is the following (and microtasks from before the actual `waitForAsync` spec are still completing -- which is valid): ``` 1. A test `beforeEach` schedules a microtask in the ProxyZone. 2. An actual empty `it` spec executes in the AsyncTestZone` (using e.g. `waitForAsync`). 3. The `onInvoke` invokes `_finishCallbackIfDone` because the spec runs synchronously. 4. We wait the scheduled timeout (see below) to account for unhandled promises. 5. The microtask from (1) finishes and `onHasTask` is invoked. --> We register a second `_finishCallbackIfDone` even though we have scheduled a timeout. --> we execute the `done` callback twice because the async zone spec state is "stable" ```
1 parent 7ea0c2a commit 4e77c7f

2 files changed

Lines changed: 67 additions & 5 deletions

File tree

packages/zone.js/lib/zone-spec/async-test.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class AsyncTestZoneSpec implements ZoneSpec {
1313
_pendingMacroTasks: boolean = false;
1414
_alreadyErrored: boolean = false;
1515
_isSync: boolean = false;
16+
_existingFinishTimer: ReturnType<typeof setTimeout>|null = null;
17+
1618
entryFunction: Function|null = null;
1719
runZone = Zone.current;
1820
unresolvedChainedPromiseCount = 0;
@@ -32,11 +34,31 @@ class AsyncTestZoneSpec implements ZoneSpec {
3234
}
3335

3436
_finishCallbackIfDone() {
37+
// NOTE: Technically the `onHasTask` could fire together with the initial synchronous
38+
// completion in `onInvoke`. `onHasTask` might call this method when it captured e.g.
39+
// microtasks in the proxy zone that now complete as part of this async zone run.
40+
// Consider the following scenario:
41+
// 1. A test `beforeEach` schedules a microtask in the ProxyZone.
42+
// 2. An actual empty `it` spec executes in the AsyncTestZone` (using e.g. `waitForAsync`).
43+
// 3. The `onInvoke` invokes `_finishCallbackIfDone` because the spec runs synchronously.
44+
// 4. We wait the scheduled timeout (see below) to account for unhandled promises.
45+
// 5. The microtask from (1) finishes and `onHasTask` is invoked.
46+
// --> We register a second `_finishCallbackIfDone` even though we have scheduled a timeout.
47+
48+
// If the finish timeout from below is already scheduled, terminate the existing scheduled
49+
// finish invocation, avoiding calling `jasmine` `done` multiple times. *Note* that we would
50+
// want to schedule a new finish callback in case the task state changes again.
51+
if (this._existingFinishTimer !== null) {
52+
clearTimeout(this._existingFinishTimer);
53+
this._existingFinishTimer = null;
54+
}
55+
3556
if (!(this._pendingMicroTasks || this._pendingMacroTasks ||
3657
(this.supportWaitUnresolvedChainedPromise && this.isUnresolvedChainedPromisePending()))) {
37-
// We do this because we would like to catch unhandled rejected promises.
58+
// We wait until the next tick because we would like to catch unhandled promises which could
59+
// cause test logic to be executed. In such cases we cannot finish with tasks pending then.
3860
this.runZone.run(() => {
39-
setTimeout(() => {
61+
this._existingFinishTimer = setTimeout(() => {
4062
if (!this._alreadyErrored && !(this._pendingMicroTasks || this._pendingMacroTasks)) {
4163
this.finishCallback();
4264
}
@@ -116,7 +138,6 @@ class AsyncTestZoneSpec implements ZoneSpec {
116138
this._isSync = true;
117139
return parentZoneDelegate.invoke(targetZone, delegate, applyThis, applyArgs, source);
118140
} finally {
119-
const afterTaskCounts: any = (parentZoneDelegate as any)._taskCounts;
120141
// We need to check the delegate is the same as entryFunction or not.
121142
// Consider the following case.
122143
//
@@ -245,7 +266,7 @@ Zone.__load_patch('asynctest', (global: any, Zone: ZoneType, api: _ZonePrivate)
245266
// Need to restore the original zone.
246267
if (proxyZoneSpec.getDelegate() == testZoneSpec) {
247268
// Only reset the zone spec if it's
248-
// sill this one. Otherwise, assume
269+
// still this one. Otherwise, assume
249270
// it's OK.
250271
proxyZoneSpec.setDelegate(previousDelegate);
251272
}

packages/zone.js/test/zone-spec/async-test.spec.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,44 @@ describe('AsyncTestZoneSpec', function() {
186186
}, 50);
187187
});
188188

189+
it('should not call done multiple times when proxy zone captures previously ' +
190+
'captured microtasks',
191+
(done) => {
192+
const ProxyZoneSpec = (Zone as any)['ProxyZoneSpec'];
193+
const proxyZoneSpec = new ProxyZoneSpec(null) as ProxyZoneSpec;
194+
const proxyZone = Zone.current.fork(proxyZoneSpec);
195+
196+
// This simulates a simple `beforeEach` patched, running in the proxy zone,
197+
// but not necessarily waiting for the promise to be resolved. This can
198+
// be the case e.g. in the AngularJS upgrade tests where the bootstrap is
199+
// performed in the before each, but the waiting is done in the actual `it` specs.
200+
proxyZone.run(() => {
201+
Promise.resolve().then(() => {});
202+
});
203+
204+
let doneCalledCount = 0;
205+
const testFn = () => {
206+
// When a test executes with `waitForAsync`, the proxy zone delegates to the async
207+
// test zone, potentially also capturing tasks leaking from `beforeEach`.
208+
proxyZoneSpec.setDelegate(testZoneSpec);
209+
};
210+
211+
const testZoneSpec = new AsyncTestZoneSpec(() => {
212+
// reset the proxy zone delegate after test completion.
213+
proxyZoneSpec.setDelegate(null);
214+
doneCalledCount++;
215+
}, () => done.fail('Error occurred in the async test zone.'), 'name');
216+
217+
const atz = Zone.current.fork(testZoneSpec);
218+
atz.run(testFn);
219+
220+
setTimeout(() => {
221+
expect(doneCalledCount).toBe(1);
222+
done();
223+
}, 50);
224+
});
225+
226+
189227
describe('event tasks', ifEnvSupports('document', () => {
190228
let button: HTMLButtonElement;
191229
beforeEach(function() {
@@ -359,7 +397,10 @@ describe('AsyncTestZoneSpec', function() {
359397
},
360398
(err: Error) => {
361399
expect(err.message).toEqual('Uncaught (in promise): my reason');
362-
done();
400+
// Without the `runInTestZone` function, the callback continues to execute
401+
// in the async test zone. We don't want to trigger new tasks upon
402+
// the failure callback already being invoked (`jasmine.done` schedules tasks)
403+
Zone.root.run(() => done());
363404
},
364405
'name');
365406

0 commit comments

Comments
 (0)