Skip to content

Commit 22f5294

Browse files
committed
Address feedback & handle interference with fakeAsync/async zone delegates
1 parent eaf8d06 commit 22f5294

File tree

8 files changed

+147
-83
lines changed

8 files changed

+147
-83
lines changed

src/cdk/testing/testbed/BUILD.bazel

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ ts_library(
1414
"//src/cdk/testing",
1515
"@npm//@angular/core",
1616
"@npm//rxjs",
17-
"@npm//zone.js",
1817
],
1918
)
2019

src/cdk/testing/testbed/task-state-zone-interceptor.ts

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,39 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
// Ensures that TypeScript knows that the global "zone.js" types
10-
// are used in this source file.
11-
/// <reference types="zone.js" />
12-
139
import {BehaviorSubject, Observable} from 'rxjs';
10+
import {ProxyZone, ProxyZoneStatic, HasTaskState, Zone, ZoneDelegate} from './zone-types';
1411

12+
/** Current state of the intercepted zone. */
1513
export interface TaskState {
14+
/** Whether the zone is stable (i.e. no microtasks and macrotasks). */
1615
stable: boolean;
1716
}
1817

19-
export class TaskStateZoneInterceptor implements ZoneSpec {
20-
/** Name of the custom zone. */
21-
readonly name = 'cdk-testing-testbed-task-state-zone';
18+
/** Unique symbol that is used to patch a property to a proxy zone. */
19+
const stateObservableSymbol = Symbol('ProxyZone_PATCHED#stateObservable');
2220

23-
/** Public observable that emits whenever the task state changes. */
24-
readonly state: Observable<TaskState>;
21+
/** Type that describes a potentially patched proxy zone instance. */
22+
type PatchedProxyZone = ProxyZone & {
23+
[stateObservableSymbol]: undefined|Observable<TaskState>;
24+
};
2525

26+
/**
27+
* Interceptor that can be set up in a `ProxyZone` instance. The interceptor
28+
* will keep track of the task state and emit whenever the state changes.
29+
*/
30+
export class TaskStateZoneInterceptor {
2631
/** Subject that can be used to emit a new state change. */
27-
private _stateSubject: BehaviorSubject<TaskState>;
32+
private _stateSubject: BehaviorSubject<TaskState> = new BehaviorSubject<TaskState>(
33+
this._lastState ? this._getTaskStateFromInternalZoneState(this._lastState) : {stable: true});
2834

29-
constructor(public lastState: HasTaskState|null) {
30-
this._stateSubject = new BehaviorSubject<TaskState>(lastState ?
31-
this._getTaskStateFromInternalZoneState(lastState) : {stable: true});
32-
this.state = this._stateSubject.asObservable();
33-
}
35+
/** Public observable that emits whenever the task state changes. */
36+
readonly state: Observable<TaskState> = this._stateSubject.asObservable();
3437

35-
/**
36-
* Implemented as part of "ZoneSpec". This will emit whenever the internal
37-
* ZoneJS task state changes.
38-
*/
38+
constructor(private _lastState: HasTaskState|null) {}
39+
40+
/** This will be called whenever the task state changes in the intercepted zone. */
3941
onHasTask(delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState) {
40-
delegate.hasTask(target, hasTaskState);
4142
if (current === target) {
4243
this._stateSubject.next(this._getTaskStateFromInternalZoneState(hasTaskState));
4344
}
@@ -47,4 +48,59 @@ export class TaskStateZoneInterceptor implements ZoneSpec {
4748
private _getTaskStateFromInternalZoneState(state: HasTaskState): TaskState {
4849
return {stable: !state.macroTask && !state.microTask};
4950
}
51+
52+
/**
53+
* Sets up the custom task state Zone interceptor in the `ProxyZone`. Throws if
54+
* no `ProxyZone` could be found.
55+
* @returns an observable that emits whenever the task state changes.
56+
*/
57+
static setup(): Observable<TaskState> {
58+
if (Zone === undefined) {
59+
throw Error('Could not find ZoneJS. For test harnesses running in TestBed, ' +
60+
'ZoneJS needs to be installed.');
61+
}
62+
63+
// tslint:disable-next-line:variable-name
64+
const ProxyZoneSpec = (Zone as any)['ProxyZoneSpec'] as ProxyZoneStatic|undefined;
65+
66+
// If there is no "ProxyZoneSpec" installed, we throw an error and recommend
67+
// setting up the proxy zone by pulling in the testing bundle.
68+
if (!ProxyZoneSpec) {
69+
throw Error(
70+
'ProxyZoneSpec is needed for the test harnesses but could not be found. ' +
71+
'Please make sure that your environment includes zone.js/dist/zone-testing.js');
72+
}
73+
74+
// Ensure that there is a proxy zone instance set up, and get
75+
// a reference to the instance if present.
76+
const zoneSpec = ProxyZoneSpec.assertPresent() as PatchedProxyZone;
77+
78+
// If there already is a delegate registered in the proxy zone, and it
79+
// is type of the custom task state interceptor, we just use that state
80+
// observable. This allows us to only intercept Zone once per test
81+
// (similar to how `fakeAsync` or `async` work).
82+
if (zoneSpec[stateObservableSymbol]) {
83+
return zoneSpec[stateObservableSymbol]!;
84+
}
85+
86+
// Since we intercept on environment creation and the fixture has been
87+
// created before, we might have missed tasks scheduled before. Fortunately
88+
// the proxy zone keeps track of the previous task state, so we can just pass
89+
// this as initial state to the task zone interceptor.
90+
const interceptor = new TaskStateZoneInterceptor(zoneSpec.lastTaskState);
91+
const zoneSpecOnHasTask = zoneSpec.onHasTask;
92+
93+
// We setup the task state interceptor in the `ProxyZone`. Note that we cannot register
94+
// the interceptor as a new proxy zone delegate because it would mean that other zone
95+
// delegates (e.g. `FakeAsyncTestZone` or `AsyncTestZone`) can accidentally overwrite/disable
96+
// our interceptor. Since we just intend to monitor the task state of the proxy zone, it is
97+
// sufficient to just patch the proxy zone. This also avoids that we interfere with the task
98+
// queue scheduling logic.
99+
zoneSpec.onHasTask = function() {
100+
zoneSpecOnHasTask.apply(zoneSpec, arguments);
101+
interceptor.onHasTask.apply(interceptor, arguments);
102+
};
103+
104+
return zoneSpec[stateObservableSymbol] = interceptor.state;
105+
}
50106
}

src/cdk/testing/testbed/testbed-harness-environment.ts

Lines changed: 12 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,14 @@
77
*/
88

99
import {HarnessEnvironment} from '@angular/cdk/testing';
10-
import {ComponentFixture} from '@angular/core/testing';
10+
import {ComponentFixture, flush} from '@angular/core/testing';
1111
import {Observable} from 'rxjs';
1212
import {takeWhile} from 'rxjs/operators';
1313
import {ComponentHarness, ComponentHarnessConstructor, HarnessLoader} from '../component-harness';
1414
import {TestElement} from '../test-element';
1515
import {TaskState, TaskStateZoneInterceptor} from './task-state-zone-interceptor';
1616
import {UnitTestElement} from './unit-test-element';
17-
import {ProxyZoneType} from './zone-types';
1817

19-
// Ensures that TypeScript knows that the global "zone.js" types
20-
// are used in this source file.
21-
/// <reference types="zone.js" />
2218

2319
/** A `HarnessEnvironment` implementation for Angular's Testbed. */
2420
export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
@@ -29,7 +25,7 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
2925

3026
protected constructor(rawRootElement: Element, private _fixture: ComponentFixture<unknown>) {
3127
super(rawRootElement);
32-
this._taskState = this._setupZoneTaskStateInterceptor();
28+
this._taskState = TaskStateZoneInterceptor.setup();
3329
_fixture.componentRef.onDestroy(() => this._destroyed = true);
3430
}
3531

@@ -66,6 +62,16 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
6662

6763
this._fixture.detectChanges();
6864

65+
// If we run in the fake async zone, we run "flush" to run any scheduled tasks. This
66+
// ensures that the harnesses behave inside of the FakeAsyncTestZone similar to the
67+
// "AsyncTestZone" and the root zone (i.e. neither fakeAsync or async). Note that we
68+
// cannot just rely on the task state observable to become stable because the state will
69+
// never change. This is because the task queue will be only drained if the fake async
70+
// zone is being flushed.
71+
if (Zone!.current.get('FakeAsyncTestZoneSpec')) {
72+
flush();
73+
}
74+
6975
// Wait until the task queue has been drained and the zone is stable. Note that
7076
// we cannot rely on "fixture.whenStable" since it does not catch tasks scheduled
7177
// outside of the Angular zone. For test harnesses, we want to ensure that the
@@ -89,53 +95,4 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
8995
await this.forceStabilize();
9096
return Array.from(this.rawRootElement.querySelectorAll(selector));
9197
}
92-
93-
/**
94-
* Sets up the custom task state ZoneJS interceptor.
95-
* @returns an observable that emits whenever the task state changes.
96-
*/
97-
private _setupZoneTaskStateInterceptor(): Observable<TaskState> {
98-
if (Zone === undefined) {
99-
throw Error('Could not find ZoneJS. For test harnesses running in TestBed, ' +
100-
'ZoneJS needs to be installed.');
101-
}
102-
103-
// tslint:disable-next-line:variable-name
104-
const ProxyZoneSpec = (Zone as any)['ProxyZoneSpec'] as ProxyZoneType|undefined;
105-
106-
// If there is no "ProxyZoneSpec" installed, we throw an error and recommend
107-
// setting up the proxy zone by pulling in the testing bundle.
108-
if (!ProxyZoneSpec) {
109-
throw Error(
110-
'ProxyZoneSpec is needed for the test harnesses but could not be found. ' +
111-
'Please make sure that your environment includes zone.js/dist/zone-testing.js');
112-
}
113-
114-
// Ensure that there is a proxy zone instance set up.
115-
ProxyZoneSpec.assertPresent();
116-
117-
// Get the instance of the proxy zone spec.
118-
const zoneSpec = ProxyZoneSpec.get();
119-
const currentDelegate = zoneSpec.getDelegate();
120-
121-
// If there already is a delegate registered in the proxy zone, and it
122-
// is type of the custom task state interceptor, we just use that state
123-
// observable. This allows us to only intercept Zone once per test
124-
// (similar to how `fakeAsync` or `async` work).
125-
if (currentDelegate && currentDelegate instanceof TaskStateZoneInterceptor) {
126-
return currentDelegate.state;
127-
}
128-
129-
// Since we intercept on environment creation and the fixture has been
130-
// created before, we might have missed tasks scheduled before. Fortunately
131-
// the proxy zone keeps track of the previous task state, so we can just pass
132-
// this as initial state to the task zone interceptor.
133-
const newZone = new TaskStateZoneInterceptor(zoneSpec.lastTaskState);
134-
135-
// Set the new delegate. This means that the "ProxyZone" will delegate
136-
// all Zone information to the custom task zone interceptor.
137-
zoneSpec.setDelegate(newZone);
138-
139-
return newZone.state;
140-
}
14198
}

src/cdk/testing/testbed/zone-types.d.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,31 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
// Ensures that TypeScript knows that the global "zone.js" types
10-
// are used in this source file.
11-
/// <reference types="zone.js" />
9+
/*
10+
* Type definitions for "zone.js". We cannot reference the official types
11+
* using a triple-slash types directive because the types would bring in
12+
* the NodeJS types into the compilation unit. This would cause unexpected
13+
* type checking failures. We just create minimal type definitions for Zone
14+
* here and use these for our interceptor logic.
15+
*/
16+
17+
declare global {
18+
// tslint:disable-next-line:variable-name
19+
const Zone: {current: any}|undefined;
20+
}
21+
22+
export type Zone = Object;
23+
export type ZoneDelegate = Object;
24+
export type HasTaskState = {microTask: boolean, macroTask: boolean};
1225

13-
export interface ProxyZoneType {
14-
assertPresent: () => void;
26+
export interface ProxyZoneStatic {
27+
assertPresent: () => ProxyZone;
1528
get(): ProxyZone;
1629
}
1730

1831
export interface ProxyZone {
1932
lastTaskState: HasTaskState|null;
2033
setDelegate(spec: ZoneSpec): void;
2134
getDelegate(): ZoneSpec;
35+
onHasTask(delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState): void;
2236
}

src/cdk/testing/tests/harnesses/main-component-harness.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export class MainComponentHarness extends ComponentHarness {
4242
readonly optionalSubComponent = this.locatorForOptional(SubComponentHarness);
4343
readonly errorSubComponent = this.locatorFor(WrongComponentHarness);
4444

45+
readonly taskStateTestTrigger = this.locatorFor('#task-state-test-trigger');
46+
readonly taskStateTestResult = this.locatorFor('#task-state-result');
47+
4548
readonly fourItemLists = this.locatorForAll(SubComponentHarness.with({itemCount: 4}));
4649
readonly toolsLists = this.locatorForAll(SubComponentHarness.with({title: 'List of test tools'}));
4750
readonly fourItemToolsLists =
@@ -96,4 +99,9 @@ export class MainComponentHarness extends ComponentHarness {
9699
async sendAltJ(): Promise<void> {
97100
return (await this.input()).sendKeys({alt: true}, 'j');
98101
}
102+
103+
async getTaskStateResult(): Promise<string> {
104+
await (await this.taskStateTestTrigger()).click();
105+
return (await this.taskStateTestResult()).text();
106+
}
99107
}

src/cdk/testing/tests/test-main-component.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,10 @@ <h1 style="height: 100px; width: 200px;">Main Component</h1>
2626
<test-sub title="other 1"></test-sub>
2727
<test-sub title="other 2"></test-sub>
2828
</div>
29+
<div class="task-state-tests">
30+
<button (click)="runTaskOutsideZone()" id="task-state-test-trigger">
31+
Run task outside zone
32+
</button>
33+
<span id="task-state-result" #taskStateResult></span>
34+
</div>
35+

src/cdk/testing/tests/test-main-component.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ChangeDetectorRef,
1313
Component,
1414
ElementRef,
15+
NgZone,
1516
OnDestroy,
1617
ViewChild,
1718
ViewEncapsulation
@@ -44,6 +45,7 @@ export class TestMainComponent implements OnDestroy {
4445
relativeY = 0;
4546

4647
@ViewChild('clickTestElement', {static: false}) clickTestElement: ElementRef<HTMLElement>;
48+
@ViewChild('taskStateResult', {static: false}) taskStateResultElement: ElementRef<HTMLElement>;
4749

4850
private _fakeOverlayElement: HTMLElement;
4951

@@ -55,7 +57,7 @@ export class TestMainComponent implements OnDestroy {
5557
this._isHovering = false;
5658
}
5759

58-
constructor(private _cdr: ChangeDetectorRef) {
60+
constructor(private _cdr: ChangeDetectorRef, private _zone: NgZone) {
5961
this.username = 'Yi';
6062
this.counter = 0;
6163
this.asyncCounter = 0;
@@ -99,4 +101,10 @@ export class TestMainComponent implements OnDestroy {
99101
this.relativeX = Math.round(event.clientX - left);
100102
this.relativeY = Math.round(event.clientY - top);
101103
}
104+
105+
runTaskOutsideZone() {
106+
this._zone.runOutsideAngular(() => setTimeout(() => {
107+
this.taskStateResultElement.nativeElement.textContent = 'result';
108+
}, 100));
109+
}
102110
}

src/cdk/testing/tests/testbed.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {HarnessLoader} from '@angular/cdk/testing';
22
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
3-
import {ComponentFixture, TestBed} from '@angular/core/testing';
3+
import {async, ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing';
44
import {FakeOverlayHarness} from './harnesses/fake-overlay-harness';
55
import {MainComponentHarness} from './harnesses/main-component-harness';
66
import {SubComponentHarness} from './harnesses/sub-component-harness';
@@ -252,6 +252,21 @@ describe('TestbedHarnessEnvironment', () => {
252252
const subcomps = await harness.directAncestorSelectorSubcomponent();
253253
expect(subcomps.length).toBe(2);
254254
});
255+
256+
it('should respect tasks outside of NgZone when stabilizing within native async/await',
257+
async () => {
258+
expect(await harness.getTaskStateResult()).toBe('result');
259+
});
260+
261+
it('should respect tasks outside of NgZone when stabilizing within async test zone',
262+
async (() => {
263+
harness.getTaskStateResult().then(res => expect(res).toBe('result'));
264+
}));
265+
266+
it('should respect tasks outside of NgZone when stabilizing within fakeAsync test zone',
267+
fakeAsync(async () => {
268+
expect(await harness.getTaskStateResult()).toBe('result');
269+
}));
255270
});
256271

257272
describe('TestElement', () => {

0 commit comments

Comments
 (0)