Skip to content

Commit

Permalink
feat(jasmine): patch jasmine to understand zones.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mhevery committed Aug 17, 2016
1 parent 6ef7451 commit 3a054be
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 59 deletions.
130 changes: 103 additions & 27 deletions lib/jasmine/jasmine.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,108 @@
'use strict';
// Patch jasmine's it and fit functions so that the `done` wrapCallback always resets the zone
// to the jasmine zone, which should be the root zone. (angular/zone.js#91)
if (!Zone) {
throw new Error('zone.js does not seem to be installed');
}
// When you have in async test (test with `done` argument) jasmine will
// execute the next test synchronously in the done handler. This makes sense
// for most tests, but now with zones. With zones running next test
// synchronously means that the current zone does not get cleared. This
// results in a chain of nested zones, which makes it hard to reason about
// it. We override the `clearStack` method which forces jasmine to always
// drain the stack before next test gets executed.
(<any>jasmine).QueueRunner = (function (SuperQueueRunner) {
const originalZone = Zone.current;
// Subclass the `QueueRunner` and override the `clearStack` method.

function alwaysClearStack(fn) {
const zone: Zone = Zone.current.getZoneWith('JasmineClearStackZone')
|| Zone.current.getZoneWith('ProxyZoneSpec')
|| originalZone;
zone.scheduleMicroTask('jasmineCleanStack', fn);
(() => {
// Patch jasmine's describe/it/beforeEach/afterEach functions so test code always runs
// in a testZone (ProxyZone). (See: angular/zone.js#91 & angular/angular#10503)
if (!Zone) throw new Error("Missing: zone.js");
if (!jasmine) throw new Error("Missing: jasmine.js");
if (jasmine['__zone_patch__']) throw new Error("'jasmine' has already been patched with 'Zone'.");
jasmine['__zone_patch__'] = true;

const SyncTestZoneSpec: {new (name: string): ZoneSpec} = Zone['SyncTestZoneSpec'];
const ProxyZoneSpec: {new (): ZoneSpec} = Zone['ProxyZoneSpec'];
if (!SyncTestZoneSpec) throw new Error("Missing: SyncTestZoneSpec");
if (!ProxyZoneSpec) throw new Error("Missing: ProxyZoneSpec");

const ambientZone = Zone.current;
// Create a synchronous-only zone in which to run `describe` blocks in order to raise an
// error if any asynchronous operations are attempted inside of a `describe` but outside of
// a `beforeEach` or `it`.
const syncZone = ambientZone.fork(new SyncTestZoneSpec('jasmine.describe'));

// This is the zone which will be used for running individual tests.
// It will be a proxy zone, so that the tests function can retroactively install
// different zones.
// Example:
// - In beforeEach() do childZone = Zone.current.fork(...);
// - In it() try to do fakeAsync(). The issue is that because the beforeEach forked the
// zone outside of fakeAsync it will be able to escope the fakeAsync rules.
// - Because ProxyZone is parent fo `childZone` fakeAsync can retroactively add
// fakeAsync behavior to the childZone.
let testProxyZone: Zone = null;

// Monkey patch all of the jasmine DSL so that each function runs in appropriate zone.
const jasmineEnv = jasmine.getEnv();
['desribe', 'xdescribe', 'fdescribe'].forEach((methodName) => {
let originalJasmineFn: Function = jasmineEnv[methodName];
jasmineEnv[methodName] = function(description: string, specDefinitions: Function) {
return originalJasmineFn.call(this, description, wrapDescribeInZone(specDefinitions));
}
});
['it', 'xit', 'fit'].forEach((methodName) => {
let originalJasmineFn: Function = jasmineEnv[methodName];
jasmineEnv[methodName] = function(description: string, specDefinitions: Function) {
return originalJasmineFn.call(this, description, wrapTestInZone(specDefinitions));
}
});
['beforeEach', 'afterEach'].forEach((methodName) => {
let originalJasmineFn: Function = jasmineEnv[methodName];
jasmineEnv[methodName] = function(specDefinitions: Function) {
return originalJasmineFn.call(this, wrapTestInZone(specDefinitions));
}
});

/**
* Gets a function wrapping the body of a Jasmine `describe` block to execute in a
* synchronous-only zone.
*/
function wrapDescribeInZone(describeBody: Function): Function {
return function() {
return syncZone.run(describeBody, this, arguments as any as any[]);
}
}

function QueueRunner(options) {
options.clearStack = alwaysClearStack;
SuperQueueRunner.call(this, options);
/**
* Gets a function wrapping the body of a Jasmine `it/beforeEach/afterEach` block to
* execute in a ProxyZone zone.
* This will run in `testProxyZone`. The `testProxyZone` will be reset by the `ZoneQueueRunner`
*/
function wrapTestInZone(testBody: Function): Function {
// The `done` callback is only passed through if the function expects at least one argument.
// Note we have to make a function with correct number of arguments, otherwise jasmine will
// think that all functions are sync or async.
return (testBody.length == 0)
? function() { return testProxyZone.run(testBody, this); }
: function(done) { return testProxyZone.run(testBody, this, [done]); };
}
interface QueueRunner {
execute(): void;
}
QueueRunner.prototype = SuperQueueRunner.prototype;
return QueueRunner;
})((<any>jasmine).QueueRunner);
interface QueueRunnerAttrs {
queueableFns: {fn: Function}[];
onComplete: () => void;
clearStack: (fn) => void;
onException: (error) => void;
catchException: () => boolean;
userContext: any;
timeout: {setTimeout: Function, clearTimeout: Function};
fail: ()=> void;
}

const QueueRunner = (jasmine as any).QueueRunner as { new(attrs: QueueRunnerAttrs): QueueRunner };
(jasmine as any).QueueRunner = class ZoneQueueRunner extends QueueRunner {
constructor(attrs: QueueRunnerAttrs) {
attrs.clearStack = (fn) => fn(); // Don't clear since onComplete will clear.
attrs.onComplete = ((fn) => () => {
// All functions are done, clear the test zone.
testProxyZone = null;
ambientZone.scheduleMicroTask('jasmine.onComplete', fn);
})(attrs.onComplete);
super(attrs);
}

execute() {
if(Zone.current !== ambientZone) throw new Error("Unexpected Zone: " + Zone.current.name);
testProxyZone = ambientZone.fork(new ProxyZoneSpec());
super.execute();
}
};
})();
2 changes: 1 addition & 1 deletion test/browser/XMLHttpRequest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('XMLHttpRequest', function () {
expect(wtfMock.log[wtfMock.log.length - 5]).toMatch(
/\> Zone\:invokeTask.*addEventListener\:readystatechange/);
expect(wtfMock.log[wtfMock.log.length - 4]).toEqual(
'> Zone:invokeTask:XMLHttpRequest.send("<root>::WTF::TestZone")');
'> Zone:invokeTask:XMLHttpRequest.send("<root>::ProxyZone::WTF::TestZone")');
expect(wtfMock.log[wtfMock.log.length - 3]).toEqual(
'< Zone:invokeTask:XMLHttpRequest.send');
expect(wtfMock.log[wtfMock.log.length - 2]).toMatch(
Expand Down
14 changes: 7 additions & 7 deletions test/common/setInterval.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ describe('setInterval', function () {
expect(Zone.current.name).toEqual(('TestZone'));
global[zoneSymbol('setTimeout')](function() {
expect(wtfMock.log).toEqual([
'# Zone:fork("<root>::WTF", "TestZone")',
'> Zone:invoke:unit-test("<root>::WTF::TestZone")',
'# Zone:schedule:macroTask:setInterval("<root>::WTF::TestZone", ' + id + ')',
'# Zone:fork("<root>::ProxyZone::WTF", "TestZone")',
'> Zone:invoke:unit-test("<root>::ProxyZone::WTF::TestZone")',
'# Zone:schedule:macroTask:setInterval("<root>::ProxyZone::WTF::TestZone", ' + id + ')',
'< Zone:invoke:unit-test',
'> Zone:invokeTask:setInterval("<root>::WTF::TestZone")',
'> Zone:invokeTask:setInterval("<root>::ProxyZone::WTF::TestZone")',
'< Zone:invokeTask:setInterval'
]);
clearInterval(cancelId);
Expand All @@ -37,9 +37,9 @@ describe('setInterval', function () {
return value;
});
expect(wtfMock.log).toEqual([
'# Zone:fork("<root>::WTF", "TestZone")',
'> Zone:invoke:unit-test("<root>::WTF::TestZone")',
'# Zone:schedule:macroTask:setInterval("<root>::WTF::TestZone", ' + id + ')'
'# Zone:fork("<root>::ProxyZone::WTF", "TestZone")',
'> Zone:invoke:unit-test("<root>::ProxyZone::WTF::TestZone")',
'# Zone:schedule:macroTask:setInterval("<root>::ProxyZone::WTF::TestZone", ' + id + ')'
]);
}, null, null, 'unit-test');
});
Expand Down
14 changes: 7 additions & 7 deletions test/common/setTimeout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ describe('setTimeout', function () {
expect(Zone.current.name).toEqual(('TestZone'));
global[zoneSymbol('setTimeout')](function () {
expect(wtfMock.log).toEqual([
'# Zone:fork("<root>::WTF", "TestZone")',
'> Zone:invoke:unit-test("<root>::WTF::TestZone")',
'# Zone:schedule:macroTask:setTimeout("<root>::WTF::TestZone", ' + id + ')',
'# Zone:fork("<root>::ProxyZone::WTF", "TestZone")',
'> Zone:invoke:unit-test("<root>::ProxyZone::WTF::TestZone")',
'# Zone:schedule:macroTask:setTimeout("<root>::ProxyZone::WTF::TestZone", ' + id + ')',
'< Zone:invoke:unit-test',
'> Zone:invokeTask:setTimeout("<root>::WTF::TestZone")',
'> Zone:invokeTask:setTimeout("<root>::ProxyZone::WTF::TestZone")',
'< Zone:invokeTask:setTimeout'
]);
done();
Expand All @@ -33,9 +33,9 @@ describe('setTimeout', function () {
return value;
});
expect(wtfMock.log).toEqual([
'# Zone:fork("<root>::WTF", "TestZone")',
'> Zone:invoke:unit-test("<root>::WTF::TestZone")',
'# Zone:schedule:macroTask:setTimeout("<root>::WTF::TestZone", ' + id + ')'
'# Zone:fork("<root>::ProxyZone::WTF", "TestZone")',
'> Zone:invoke:unit-test("<root>::ProxyZone::WTF::TestZone")',
'# Zone:schedule:macroTask:setTimeout("<root>::ProxyZone::WTF::TestZone", ' + id + ')'
]);
}, null, null, 'unit-test');
});
Expand Down
7 changes: 4 additions & 3 deletions test/common/zone.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,17 @@ describe('Zone', function () {
});

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

zoneA.run(function () {
zoneB.run(function () {
expect(Zone.current).toBe(zoneB);
});
expect(Zone.current).toBe(zoneA);
});
expect(Zone.current).toBe(rootZone);
expect(Zone.current).toBe(zone);
});


Expand Down
29 changes: 29 additions & 0 deletions test/jasmine-patch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
describe('jasmine', () => {
let throwOnAsync = false;
let beforeEachZone: Zone = null;
let itZone: Zone = null;
const syncZone = Zone.current;
try {
Zone.current.scheduleMicroTask('dontallow', () => null);
} catch(e) {
throwOnAsync = true;
}

beforeEach(() => beforeEachZone = Zone.current);

it('should throw on async in describe', () => {
expect(throwOnAsync).toBe(true);
expect(syncZone.name).toEqual('syncTestZone for jasmine.describe');
itZone = Zone.current;
});

afterEach(() => {
let zone = Zone.current;
expect(zone.name).toEqual('ProxyZone');
expect(beforeEachZone).toBe(zone);
expect(itZone).toBe(zone);
});

});

export var _something_so_that_i_am_treated_as_es6_module;
20 changes: 13 additions & 7 deletions test/zone-spec/proxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ describe('ProxySpec', () => {
});

it('should assert that it is in or out of ProxyZone', () => {
expect(() => ProxyZoneSpec.assertPresent()).toThrow();
expect(ProxyZoneSpec.isLoaded()).toBe(false);
expect(ProxyZoneSpec.get()).toBe(undefined);
proxyZone.run(() => {
expect(ProxyZoneSpec.isLoaded()).toBe(true);
expect(() => ProxyZoneSpec.assertPresent()).not.toThrow();
expect(ProxyZoneSpec.get()).toBe(proxyZoneSpec);
let rootZone = Zone.current;
while(rootZone.parent) {
rootZone = rootZone.parent;
}
rootZone.run(() => {
expect(() => ProxyZoneSpec.assertPresent()).toThrow();
expect(ProxyZoneSpec.isLoaded()).toBe(false);
expect(ProxyZoneSpec.get()).toBe(undefined);
proxyZone.run(() => {
expect(ProxyZoneSpec.isLoaded()).toBe(true);
expect(() => ProxyZoneSpec.assertPresent()).not.toThrow();
expect(ProxyZoneSpec.get()).toBe(proxyZoneSpec);
});
});
});

Expand Down
11 changes: 4 additions & 7 deletions test/zone-spec/task-tracking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ describe('TaskTrackingZone', function() {
expect(taskTrackingZoneSpec.macroTasks.length).toBe(0);
expect(taskTrackingZoneSpec.microTasks.length).toBe(0);

// If a browser does not have XMLHttpRequest, then end test here.
if (global['XMLHttpRequest']) return done();
const xhr = new XMLHttpRequest();
xhr.open('get', '/', true);
xhr.onreadystatechange = () => {
Expand All @@ -35,7 +37,7 @@ describe('TaskTrackingZone', function() {
setTimeout(() => {
expect(taskTrackingZoneSpec.macroTasks.length).toBe(0);
expect(taskTrackingZoneSpec.microTasks.length).toBe(0);
expect(taskTrackingZoneSpec.eventTasks.length).toBe(2);
expect(taskTrackingZoneSpec.eventTasks.length).not.toBe(0);
taskTrackingZoneSpec.clearEvents();
expect(taskTrackingZoneSpec.eventTasks.length).toBe(0);
done();
Expand All @@ -45,12 +47,7 @@ describe('TaskTrackingZone', function() {
xhr.send();
expect(taskTrackingZoneSpec.macroTasks.length).toBe(1);
expect(taskTrackingZoneSpec.macroTasks[0].source).toBe('XMLHttpRequest.send');

expect(taskTrackingZoneSpec.eventTasks.length).toBe(2);
// one for me
expect(taskTrackingZoneSpec.eventTasks[0].source).toBe('XMLHttpRequest.addEventListener:readystatechange');
// one for internall tracking of XHRs.
expect(taskTrackingZoneSpec.eventTasks[1].source).toBe('XMLHttpRequest.addEventListener:readystatechange');
expect(taskTrackingZoneSpec.eventTasks[0].source).toMatch(/\.addEventListener:readystatechange/);
});

});
Expand Down

0 comments on commit 3a054be

Please sign in to comment.