Skip to content
This repository was archived by the owner on Feb 26, 2024. It is now read-only.

Commit 3a054be

Browse files
committed
feat(jasmine): patch jasmine to understand zones.
jasmine now understands zones and follows these rules: - Jasmine itself runs in ambient zone (most likely the root Zone). - Describe calls run in SyncZone which prevent async operations from being spawned from within the describe blocks. - beforeEach/it/afterEach run in ProxyZone, which allows tests to retroactively set zone rules. - Each test runs in a new instance of the ProxyZone.
1 parent 6ef7451 commit 3a054be

File tree

8 files changed

+168
-59
lines changed

8 files changed

+168
-59
lines changed

lib/jasmine/jasmine.ts

Lines changed: 103 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,108 @@
11
'use strict';
2-
// Patch jasmine's it and fit functions so that the `done` wrapCallback always resets the zone
3-
// to the jasmine zone, which should be the root zone. (angular/zone.js#91)
4-
if (!Zone) {
5-
throw new Error('zone.js does not seem to be installed');
6-
}
7-
// When you have in async test (test with `done` argument) jasmine will
8-
// execute the next test synchronously in the done handler. This makes sense
9-
// for most tests, but now with zones. With zones running next test
10-
// synchronously means that the current zone does not get cleared. This
11-
// results in a chain of nested zones, which makes it hard to reason about
12-
// it. We override the `clearStack` method which forces jasmine to always
13-
// drain the stack before next test gets executed.
14-
(<any>jasmine).QueueRunner = (function (SuperQueueRunner) {
15-
const originalZone = Zone.current;
16-
// Subclass the `QueueRunner` and override the `clearStack` method.
17-
18-
function alwaysClearStack(fn) {
19-
const zone: Zone = Zone.current.getZoneWith('JasmineClearStackZone')
20-
|| Zone.current.getZoneWith('ProxyZoneSpec')
21-
|| originalZone;
22-
zone.scheduleMicroTask('jasmineCleanStack', fn);
2+
(() => {
3+
// Patch jasmine's describe/it/beforeEach/afterEach functions so test code always runs
4+
// in a testZone (ProxyZone). (See: angular/zone.js#91 & angular/angular#10503)
5+
if (!Zone) throw new Error("Missing: zone.js");
6+
if (!jasmine) throw new Error("Missing: jasmine.js");
7+
if (jasmine['__zone_patch__']) throw new Error("'jasmine' has already been patched with 'Zone'.");
8+
jasmine['__zone_patch__'] = true;
9+
10+
const SyncTestZoneSpec: {new (name: string): ZoneSpec} = Zone['SyncTestZoneSpec'];
11+
const ProxyZoneSpec: {new (): ZoneSpec} = Zone['ProxyZoneSpec'];
12+
if (!SyncTestZoneSpec) throw new Error("Missing: SyncTestZoneSpec");
13+
if (!ProxyZoneSpec) throw new Error("Missing: ProxyZoneSpec");
14+
15+
const ambientZone = Zone.current;
16+
// Create a synchronous-only zone in which to run `describe` blocks in order to raise an
17+
// error if any asynchronous operations are attempted inside of a `describe` but outside of
18+
// a `beforeEach` or `it`.
19+
const syncZone = ambientZone.fork(new SyncTestZoneSpec('jasmine.describe'));
20+
21+
// This is the zone which will be used for running individual tests.
22+
// It will be a proxy zone, so that the tests function can retroactively install
23+
// different zones.
24+
// Example:
25+
// - In beforeEach() do childZone = Zone.current.fork(...);
26+
// - In it() try to do fakeAsync(). The issue is that because the beforeEach forked the
27+
// zone outside of fakeAsync it will be able to escope the fakeAsync rules.
28+
// - Because ProxyZone is parent fo `childZone` fakeAsync can retroactively add
29+
// fakeAsync behavior to the childZone.
30+
let testProxyZone: Zone = null;
31+
32+
// Monkey patch all of the jasmine DSL so that each function runs in appropriate zone.
33+
const jasmineEnv = jasmine.getEnv();
34+
['desribe', 'xdescribe', 'fdescribe'].forEach((methodName) => {
35+
let originalJasmineFn: Function = jasmineEnv[methodName];
36+
jasmineEnv[methodName] = function(description: string, specDefinitions: Function) {
37+
return originalJasmineFn.call(this, description, wrapDescribeInZone(specDefinitions));
38+
}
39+
});
40+
['it', 'xit', 'fit'].forEach((methodName) => {
41+
let originalJasmineFn: Function = jasmineEnv[methodName];
42+
jasmineEnv[methodName] = function(description: string, specDefinitions: Function) {
43+
return originalJasmineFn.call(this, description, wrapTestInZone(specDefinitions));
44+
}
45+
});
46+
['beforeEach', 'afterEach'].forEach((methodName) => {
47+
let originalJasmineFn: Function = jasmineEnv[methodName];
48+
jasmineEnv[methodName] = function(specDefinitions: Function) {
49+
return originalJasmineFn.call(this, wrapTestInZone(specDefinitions));
50+
}
51+
});
52+
53+
/**
54+
* Gets a function wrapping the body of a Jasmine `describe` block to execute in a
55+
* synchronous-only zone.
56+
*/
57+
function wrapDescribeInZone(describeBody: Function): Function {
58+
return function() {
59+
return syncZone.run(describeBody, this, arguments as any as any[]);
60+
}
2361
}
2462

25-
function QueueRunner(options) {
26-
options.clearStack = alwaysClearStack;
27-
SuperQueueRunner.call(this, options);
63+
/**
64+
* Gets a function wrapping the body of a Jasmine `it/beforeEach/afterEach` block to
65+
* execute in a ProxyZone zone.
66+
* This will run in `testProxyZone`. The `testProxyZone` will be reset by the `ZoneQueueRunner`
67+
*/
68+
function wrapTestInZone(testBody: Function): Function {
69+
// The `done` callback is only passed through if the function expects at least one argument.
70+
// Note we have to make a function with correct number of arguments, otherwise jasmine will
71+
// think that all functions are sync or async.
72+
return (testBody.length == 0)
73+
? function() { return testProxyZone.run(testBody, this); }
74+
: function(done) { return testProxyZone.run(testBody, this, [done]); };
75+
}
76+
interface QueueRunner {
77+
execute(): void;
2878
}
29-
QueueRunner.prototype = SuperQueueRunner.prototype;
30-
return QueueRunner;
31-
})((<any>jasmine).QueueRunner);
79+
interface QueueRunnerAttrs {
80+
queueableFns: {fn: Function}[];
81+
onComplete: () => void;
82+
clearStack: (fn) => void;
83+
onException: (error) => void;
84+
catchException: () => boolean;
85+
userContext: any;
86+
timeout: {setTimeout: Function, clearTimeout: Function};
87+
fail: ()=> void;
88+
}
89+
90+
const QueueRunner = (jasmine as any).QueueRunner as { new(attrs: QueueRunnerAttrs): QueueRunner };
91+
(jasmine as any).QueueRunner = class ZoneQueueRunner extends QueueRunner {
92+
constructor(attrs: QueueRunnerAttrs) {
93+
attrs.clearStack = (fn) => fn(); // Don't clear since onComplete will clear.
94+
attrs.onComplete = ((fn) => () => {
95+
// All functions are done, clear the test zone.
96+
testProxyZone = null;
97+
ambientZone.scheduleMicroTask('jasmine.onComplete', fn);
98+
})(attrs.onComplete);
99+
super(attrs);
100+
}
32101

102+
execute() {
103+
if(Zone.current !== ambientZone) throw new Error("Unexpected Zone: " + Zone.current.name);
104+
testProxyZone = ambientZone.fork(new ProxyZoneSpec());
105+
super.execute();
106+
}
107+
};
108+
})();

test/browser/XMLHttpRequest.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe('XMLHttpRequest', function () {
1515
expect(wtfMock.log[wtfMock.log.length - 5]).toMatch(
1616
/\> Zone\:invokeTask.*addEventListener\:readystatechange/);
1717
expect(wtfMock.log[wtfMock.log.length - 4]).toEqual(
18-
'> Zone:invokeTask:XMLHttpRequest.send("<root>::WTF::TestZone")');
18+
'> Zone:invokeTask:XMLHttpRequest.send("<root>::ProxyZone::WTF::TestZone")');
1919
expect(wtfMock.log[wtfMock.log.length - 3]).toEqual(
2020
'< Zone:invokeTask:XMLHttpRequest.send');
2121
expect(wtfMock.log[wtfMock.log.length - 2]).toMatch(

test/common/setInterval.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ describe('setInterval', function () {
1212
expect(Zone.current.name).toEqual(('TestZone'));
1313
global[zoneSymbol('setTimeout')](function() {
1414
expect(wtfMock.log).toEqual([
15-
'# Zone:fork("<root>::WTF", "TestZone")',
16-
'> Zone:invoke:unit-test("<root>::WTF::TestZone")',
17-
'# Zone:schedule:macroTask:setInterval("<root>::WTF::TestZone", ' + id + ')',
15+
'# Zone:fork("<root>::ProxyZone::WTF", "TestZone")',
16+
'> Zone:invoke:unit-test("<root>::ProxyZone::WTF::TestZone")',
17+
'# Zone:schedule:macroTask:setInterval("<root>::ProxyZone::WTF::TestZone", ' + id + ')',
1818
'< Zone:invoke:unit-test',
19-
'> Zone:invokeTask:setInterval("<root>::WTF::TestZone")',
19+
'> Zone:invokeTask:setInterval("<root>::ProxyZone::WTF::TestZone")',
2020
'< Zone:invokeTask:setInterval'
2121
]);
2222
clearInterval(cancelId);
@@ -37,9 +37,9 @@ describe('setInterval', function () {
3737
return value;
3838
});
3939
expect(wtfMock.log).toEqual([
40-
'# Zone:fork("<root>::WTF", "TestZone")',
41-
'> Zone:invoke:unit-test("<root>::WTF::TestZone")',
42-
'# Zone:schedule:macroTask:setInterval("<root>::WTF::TestZone", ' + id + ')'
40+
'# Zone:fork("<root>::ProxyZone::WTF", "TestZone")',
41+
'> Zone:invoke:unit-test("<root>::ProxyZone::WTF::TestZone")',
42+
'# Zone:schedule:macroTask:setInterval("<root>::ProxyZone::WTF::TestZone", ' + id + ')'
4343
]);
4444
}, null, null, 'unit-test');
4545
});

test/common/setTimeout.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ describe('setTimeout', function () {
1010
expect(Zone.current.name).toEqual(('TestZone'));
1111
global[zoneSymbol('setTimeout')](function () {
1212
expect(wtfMock.log).toEqual([
13-
'# Zone:fork("<root>::WTF", "TestZone")',
14-
'> Zone:invoke:unit-test("<root>::WTF::TestZone")',
15-
'# Zone:schedule:macroTask:setTimeout("<root>::WTF::TestZone", ' + id + ')',
13+
'# Zone:fork("<root>::ProxyZone::WTF", "TestZone")',
14+
'> Zone:invoke:unit-test("<root>::ProxyZone::WTF::TestZone")',
15+
'# Zone:schedule:macroTask:setTimeout("<root>::ProxyZone::WTF::TestZone", ' + id + ')',
1616
'< Zone:invoke:unit-test',
17-
'> Zone:invokeTask:setTimeout("<root>::WTF::TestZone")',
17+
'> Zone:invokeTask:setTimeout("<root>::ProxyZone::WTF::TestZone")',
1818
'< Zone:invokeTask:setTimeout'
1919
]);
2020
done();
@@ -33,9 +33,9 @@ describe('setTimeout', function () {
3333
return value;
3434
});
3535
expect(wtfMock.log).toEqual([
36-
'# Zone:fork("<root>::WTF", "TestZone")',
37-
'> Zone:invoke:unit-test("<root>::WTF::TestZone")',
38-
'# Zone:schedule:macroTask:setTimeout("<root>::WTF::TestZone", ' + id + ')'
36+
'# Zone:fork("<root>::ProxyZone::WTF", "TestZone")',
37+
'> Zone:invoke:unit-test("<root>::ProxyZone::WTF::TestZone")',
38+
'# Zone:schedule:macroTask:setTimeout("<root>::ProxyZone::WTF::TestZone", ' + id + ')'
3939
]);
4040
}, null, null, 'unit-test');
4141
});

test/common/zone.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,17 @@ describe('Zone', function () {
3434
});
3535

3636
it('should allow zones to be run from within another zone', function () {
37-
var zoneA = Zone.current.fork({ name: 'A' });
38-
var zoneB = Zone.current.fork({ name: 'B' });
37+
var zone = Zone.current;
38+
var zoneA = zone.fork({ name: 'A' });
39+
var zoneB = zone.fork({ name: 'B' });
3940

4041
zoneA.run(function () {
4142
zoneB.run(function () {
4243
expect(Zone.current).toBe(zoneB);
4344
});
4445
expect(Zone.current).toBe(zoneA);
4546
});
46-
expect(Zone.current).toBe(rootZone);
47+
expect(Zone.current).toBe(zone);
4748
});
4849

4950

test/jasmine-patch.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
describe('jasmine', () => {
2+
let throwOnAsync = false;
3+
let beforeEachZone: Zone = null;
4+
let itZone: Zone = null;
5+
const syncZone = Zone.current;
6+
try {
7+
Zone.current.scheduleMicroTask('dontallow', () => null);
8+
} catch(e) {
9+
throwOnAsync = true;
10+
}
11+
12+
beforeEach(() => beforeEachZone = Zone.current);
13+
14+
it('should throw on async in describe', () => {
15+
expect(throwOnAsync).toBe(true);
16+
expect(syncZone.name).toEqual('syncTestZone for jasmine.describe');
17+
itZone = Zone.current;
18+
});
19+
20+
afterEach(() => {
21+
let zone = Zone.current;
22+
expect(zone.name).toEqual('ProxyZone');
23+
expect(beforeEachZone).toBe(zone);
24+
expect(itZone).toBe(zone);
25+
});
26+
27+
});
28+
29+
export var _something_so_that_i_am_treated_as_es6_module;

test/zone-spec/proxy.spec.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,19 @@ describe('ProxySpec', () => {
2020
});
2121

2222
it('should assert that it is in or out of ProxyZone', () => {
23-
expect(() => ProxyZoneSpec.assertPresent()).toThrow();
24-
expect(ProxyZoneSpec.isLoaded()).toBe(false);
25-
expect(ProxyZoneSpec.get()).toBe(undefined);
26-
proxyZone.run(() => {
27-
expect(ProxyZoneSpec.isLoaded()).toBe(true);
28-
expect(() => ProxyZoneSpec.assertPresent()).not.toThrow();
29-
expect(ProxyZoneSpec.get()).toBe(proxyZoneSpec);
23+
let rootZone = Zone.current;
24+
while(rootZone.parent) {
25+
rootZone = rootZone.parent;
26+
}
27+
rootZone.run(() => {
28+
expect(() => ProxyZoneSpec.assertPresent()).toThrow();
29+
expect(ProxyZoneSpec.isLoaded()).toBe(false);
30+
expect(ProxyZoneSpec.get()).toBe(undefined);
31+
proxyZone.run(() => {
32+
expect(ProxyZoneSpec.isLoaded()).toBe(true);
33+
expect(() => ProxyZoneSpec.assertPresent()).not.toThrow();
34+
expect(ProxyZoneSpec.get()).toBe(proxyZoneSpec);
35+
});
3036
});
3137
});
3238

test/zone-spec/task-tracking.spec.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ describe('TaskTrackingZone', function() {
2727
expect(taskTrackingZoneSpec.macroTasks.length).toBe(0);
2828
expect(taskTrackingZoneSpec.microTasks.length).toBe(0);
2929

30+
// If a browser does not have XMLHttpRequest, then end test here.
31+
if (global['XMLHttpRequest']) return done();
3032
const xhr = new XMLHttpRequest();
3133
xhr.open('get', '/', true);
3234
xhr.onreadystatechange = () => {
@@ -35,7 +37,7 @@ describe('TaskTrackingZone', function() {
3537
setTimeout(() => {
3638
expect(taskTrackingZoneSpec.macroTasks.length).toBe(0);
3739
expect(taskTrackingZoneSpec.microTasks.length).toBe(0);
38-
expect(taskTrackingZoneSpec.eventTasks.length).toBe(2);
40+
expect(taskTrackingZoneSpec.eventTasks.length).not.toBe(0);
3941
taskTrackingZoneSpec.clearEvents();
4042
expect(taskTrackingZoneSpec.eventTasks.length).toBe(0);
4143
done();
@@ -45,12 +47,7 @@ describe('TaskTrackingZone', function() {
4547
xhr.send();
4648
expect(taskTrackingZoneSpec.macroTasks.length).toBe(1);
4749
expect(taskTrackingZoneSpec.macroTasks[0].source).toBe('XMLHttpRequest.send');
48-
49-
expect(taskTrackingZoneSpec.eventTasks.length).toBe(2);
50-
// one for me
51-
expect(taskTrackingZoneSpec.eventTasks[0].source).toBe('XMLHttpRequest.addEventListener:readystatechange');
52-
// one for internall tracking of XHRs.
53-
expect(taskTrackingZoneSpec.eventTasks[1].source).toBe('XMLHttpRequest.addEventListener:readystatechange');
50+
expect(taskTrackingZoneSpec.eventTasks[0].source).toMatch(/\.addEventListener:readystatechange/);
5451
});
5552

5653
});

0 commit comments

Comments
 (0)