Skip to content

Commit 1ea39e0

Browse files
committed
refactor(zone.js): Add a withProxyZone helper that might be used for unpatched test frameworks (angular#61626)
As an alternative to monkey patching vitest, this change adds a method that could be used for manually running functions inside a shared proxy zone. If used inocrrectly, this would mean that the `fakeAsync` closure may not capture all timers and microtasks if it invokes things created in a zone that was already forked (e.g. creating a component in a beforeEach: https://github.com/angular/angular/blob/2699dd65558d70fadeac1e9b420841f3dfc3a059/packages/zone.js/lib/jasmine/jasmine.ts#L363-L371) PR Close angular#61626
1 parent 768e715 commit 1ea39e0

File tree

8 files changed

+757
-18
lines changed

8 files changed

+757
-18
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ jobs:
174174
- run: yarn --cwd packages/zone.js promisefinallytest
175175
- run: yarn --cwd packages/zone.js jest:test
176176
- run: yarn --cwd packages/zone.js jest:nodetest
177+
- run: yarn --cwd packages/zone.js vitest:test
177178
- run: yarn --cwd packages/zone.js electrontest
178179
- run: yarn --cwd packages/zone.js/test/typings install --frozen-lockfile --non-interactive
179180
- run: yarn --cwd packages/zone.js/test/typings test

.github/workflows/pr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ jobs:
185185
- run: yarn --cwd packages/zone.js promisefinallytest
186186
- run: yarn --cwd packages/zone.js jest:test
187187
- run: yarn --cwd packages/zone.js jest:nodetest
188+
- run: yarn --cwd packages/zone.js vitest:test
188189
- run: yarn --cwd packages/zone.js electrontest
189190
- run: yarn --cwd packages/zone.js/test/typings install --frozen-lockfile --non-interactive
190191
- run: yarn --cwd packages/zone.js/test/typings test
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import 'zone.js';
2+
import {fakeAsync as fakeAsyncInternal} from '@angular/core/testing';
3+
4+
export function fakeAsync(fn: Function): Function {
5+
return withProxyZone(fn);
6+
}
7+
export function withProxyZone(fn: Function): Function {
8+
const autoProxyFn = function (this: unknown, ...args: any[]) {
9+
const proxyZoneSpec = (Zone as any)['ProxyZoneSpec'];
10+
11+
const _sharedAutoProxyZoneSpec = new proxyZoneSpec();
12+
const zone = Zone.root.fork(_sharedAutoProxyZoneSpec);
13+
14+
return zone.run(fakeAsyncInternal(fn), this, args);
15+
};
16+
return autoProxyFn;
17+
}

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

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {ZoneType} from '../zone-impl';
10+
import {type ProxyZoneSpec} from './proxy';
1011

1112
const global: any =
1213
(typeof window === 'object' && window) || (typeof self === 'object' && self) || globalThis.global;
@@ -789,15 +790,13 @@ class FakeAsyncTestZoneSpec implements ZoneSpec {
789790

790791
let _fakeAsyncTestZoneSpec: FakeAsyncTestZoneSpec | null = null;
791792

792-
type ProxyZoneSpecType = {
793-
setDelegate(delegateSpec: ZoneSpec): void;
794-
getDelegate(): ZoneSpec;
795-
resetDelegate(): void;
796-
};
797-
function getProxyZoneSpec(): {get(): ProxyZoneSpecType; assertPresent: () => ProxyZoneSpecType} {
793+
function getProxyZoneSpec(): typeof ProxyZoneSpec | undefined {
798794
return Zone && (Zone as any)['ProxyZoneSpec'];
799795
}
800796

797+
let _sharedProxyZoneSpec: ProxyZoneSpec | null = null;
798+
let _sharedProxyZone: Zone | null = null;
799+
801800
/**
802801
* Clears out the shared fake async zone for a test.
803802
* To be called in a global `beforeEach`.
@@ -809,8 +808,8 @@ export function resetFakeAsyncZone() {
809808
_fakeAsyncTestZoneSpec.unlockDatePatch();
810809
}
811810
_fakeAsyncTestZoneSpec = null;
812-
// in node.js testing we may not have ProxyZoneSpec in which case there is nothing to reset.
813-
getProxyZoneSpec() && getProxyZoneSpec().assertPresent().resetDelegate();
811+
getProxyZoneSpec()?.get()?.resetDelegate();
812+
_sharedProxyZoneSpec?.resetDelegate();
814813
}
815814

816815
/**
@@ -841,8 +840,8 @@ export function fakeAsync(fn: Function, options: {flush?: boolean} = {}): (...ar
841840
const ProxyZoneSpec = getProxyZoneSpec();
842841
if (!ProxyZoneSpec) {
843842
throw new Error(
844-
'ProxyZoneSpec is needed for the async() test helper but could not be found. ' +
845-
'Please make sure that your environment includes zone.js/plugins/proxy',
843+
'ProxyZoneSpec is needed for the fakeAsync() test helper but could not be found. ' +
844+
'Make sure that your environment includes zone-testing.js',
846845
);
847846
}
848847
const proxyZoneSpec = ProxyZoneSpec.assertPresent();
@@ -894,7 +893,7 @@ export function fakeAsync(fn: Function, options: {flush?: boolean} = {}): (...ar
894893
resetFakeAsyncZone();
895894
}
896895
};
897-
(fakeAsyncFn as any).isFakeAsync = true;
896+
fakeAsyncFn.isFakeAsync = true;
898897
return fakeAsyncFn;
899898
}
900899

@@ -949,6 +948,51 @@ export function discardPeriodicTasks(): void {
949948
zoneSpec.pendingPeriodicTimers.length = 0;
950949
}
951950

951+
/**
952+
* Wraps a function to be executed in a shared ProxyZone.
953+
*
954+
* If no shared ProxyZone exists, one is created and reused for subsequent calls.
955+
* Useful for wrapping test setup (beforeEach) and test execution (it) when test
956+
* runner patching isn't available or desired for setting up the ProxyZone.
957+
*
958+
* @param fn The function to wrap.
959+
* @returns A function that executes the original function within the shared ProxyZone.
960+
*
961+
* @experimental
962+
*/
963+
export function withProxyZone<T extends Function>(fn: T): T {
964+
const autoProxyFn: any = function (this: unknown, ...args: any[]) {
965+
const proxyZoneSpec = getProxyZoneSpec();
966+
if (proxyZoneSpec === undefined) {
967+
throw new Error(
968+
'ProxyZoneSpec is needed for the withProxyZone() test helper but could not be found. ' +
969+
'Make sure that your environment includes zone-testing.js',
970+
);
971+
}
972+
973+
const proxyZone = proxyZoneSpec.get() !== undefined ? Zone.current : getOrCreateRootProxy();
974+
return proxyZone.run(fn, this, args);
975+
};
976+
return autoProxyFn as T;
977+
}
978+
979+
function getOrCreateRootProxy() {
980+
const ProxyZoneSpec = getProxyZoneSpec();
981+
if (ProxyZoneSpec === undefined) {
982+
throw new Error(
983+
'ProxyZoneSpec is needed for withProxyZone but could not be found. ' +
984+
'Make sure that your environment includes zone-testing.js',
985+
);
986+
}
987+
// Ensure the shared ProxyZoneSpec instance exists
988+
if (_sharedProxyZoneSpec === null) {
989+
_sharedProxyZoneSpec = new ProxyZoneSpec() as ProxyZoneSpec;
990+
}
991+
992+
_sharedProxyZone = Zone.root.fork(_sharedProxyZoneSpec);
993+
return _sharedProxyZone;
994+
}
995+
952996
/**
953997
* Flush any pending microtasks.
954998
*
@@ -973,6 +1017,7 @@ export function patchFakeAsyncTest(Zone: ZoneType): void {
9731017
tick,
9741018
flush,
9751019
fakeAsync,
1020+
withProxyZone,
9761021
};
9771022
},
9781023
true,

packages/zone.js/lib/zone-spec/proxy.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export class ProxyZoneSpec implements ZoneSpec {
2121

2222
private tasks: Task[] = [];
2323

24-
static get(): ProxyZoneSpec {
24+
static get(): ProxyZoneSpec | undefined {
2525
return Zone.current.get('ProxyZoneSpec');
2626
}
2727

@@ -30,10 +30,11 @@ export class ProxyZoneSpec implements ZoneSpec {
3030
}
3131

3232
static assertPresent(): ProxyZoneSpec {
33-
if (!ProxyZoneSpec.isLoaded()) {
33+
const spec = ProxyZoneSpec.get();
34+
if (spec === undefined) {
3435
throw new Error(`Expected to be running in 'ProxyZone', but it was not found.`);
3536
}
36-
return ProxyZoneSpec.get();
37+
return spec;
3738
}
3839

3940
constructor(private defaultSpecDelegate: ZoneSpec | null = null) {

packages/zone.js/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
"jest-environment-node": "^29.0.3",
1818
"mocha": "^11.0.0",
1919
"mock-require": "3.0.3",
20-
"tslib": "^2.3.0"
20+
"tslib": "^2.3.0",
21+
"vitest": "^3.1.3"
2122
},
2223
"scripts": {
2324
"electrontest": "cd test/extra && node electron.js",
2425
"jest:test": "jest --config ./test/jest/jest.config.js ./test/jest/jest.spec.js",
2526
"jest:nodetest": "jest --config ./test/jest/jest.node.config.js ./test/jest/jest.spec.js",
27+
"vitest:test": "vitest ./test/vitest/vitest.spec.js",
2628
"promisefinallytest": "mocha ./test/promise/promise.finally.spec.mjs"
2729
},
2830
"repository": {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
require('../../../../dist/bin/packages/zone.js/npm_package/bundles/zone.umd.js');
2+
require('../../../../dist/bin/packages/zone.js/npm_package/bundles/zone-testing.umd.js');
3+
4+
import {expect, test, describe, beforeEach} from 'vitest';
5+
6+
const {tick, withProxyZone, fakeAsync} = Zone[Zone.__symbol__('fakeAsyncTest')];
7+
8+
describe('proxy zone behavior', () => {
9+
const spec = new Zone['ProxyZoneSpec']();
10+
const proxyZone = Zone.root.fork(spec);
11+
12+
function createForkedZone() {
13+
const AsyncTestZoneSpec = Zone['AsyncTestZoneSpec'];
14+
return Zone.current.fork(
15+
new AsyncTestZoneSpec(
16+
() => {},
17+
() => {},
18+
'asyncTest',
19+
),
20+
);
21+
}
22+
23+
test('cannot run fakeAsync outside proxy zone', () => {
24+
expect(fakeAsync(() => {})).toThrow();
25+
});
26+
27+
test('can run fakeAsync inside proxy zone', () => {
28+
expect(() => {
29+
proxyZone.run(fakeAsync(() => {}));
30+
}).not.toThrow();
31+
});
32+
33+
test('can flush timeouts in forked zone if created in proxy', () => {
34+
let forkedZone;
35+
proxyZone.run(() => {
36+
forkedZone = createForkedZone();
37+
});
38+
39+
proxyZone.run(
40+
fakeAsync(() => {
41+
let x = 1;
42+
forkedZone.run(() => {
43+
setTimeout(() => void (x = 2), 5000);
44+
});
45+
46+
tick(5000);
47+
expect(x).toBe(2);
48+
}),
49+
);
50+
});
51+
52+
test('cannot flush timeouts in forked zone if created outside proxy', () => {
53+
// This test is similar to creating a component in a beforeEach, which forks the zone to create the Angular NgZone
54+
const forkedZone = createForkedZone();
55+
proxyZone.run(() => {
56+
fakeAsync(() => {
57+
let x = 1;
58+
forkedZone.run(() => {
59+
setTimeout(() => void (x = 2), 5000);
60+
});
61+
62+
tick(5000);
63+
expect(x).toBe(1);
64+
})();
65+
});
66+
});
67+
});
68+
69+
test(
70+
'withProxyZone runs inside proxy zone',
71+
withProxyZone(() => {
72+
expect(Zone.current.name).toEqual('ProxyZone');
73+
}),
74+
);
75+
76+
test(
77+
'can use fakeAsync in proxy zone in test body',
78+
withProxyZone(
79+
fakeAsync(() => {
80+
let x = 1;
81+
setTimeout(() => void (x = 2), 5000);
82+
tick(5000);
83+
expect(x).toBe(2);
84+
}),
85+
),
86+
);
87+
88+
describe('can use withProxyZone and beforeEach', () => {
89+
let forkedZone;
90+
beforeEach(
91+
withProxyZone(() => {
92+
const AsyncTestZoneSpec = Zone['AsyncTestZoneSpec'];
93+
forkedZone = Zone.current.fork(
94+
new AsyncTestZoneSpec(
95+
() => {},
96+
() => {},
97+
'asyncTest',
98+
),
99+
);
100+
}),
101+
);
102+
103+
test(
104+
'withProxyZone(fakeAsync)',
105+
withProxyZone(
106+
fakeAsync(() => {
107+
let x = 1;
108+
forkedZone.run(() => {
109+
setTimeout(() => void (x = 2), 5000);
110+
});
111+
tick(5000);
112+
expect(x).toBe(2);
113+
}),
114+
),
115+
);
116+
});

0 commit comments

Comments
 (0)