Skip to content

Commit eaf8d06

Browse files
committed
fix(cdk/testing): testbed harness stabilizing not handling tasks outside of NgZone.
WIP
1 parent 1b94295 commit eaf8d06

File tree

4 files changed

+141
-1
lines changed

4 files changed

+141
-1
lines changed

src/cdk/testing/testbed/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ ts_library(
1313
"//src/cdk/keycodes",
1414
"//src/cdk/testing",
1515
"@npm//@angular/core",
16+
"@npm//rxjs",
17+
"@npm//zone.js",
1618
],
1719
)
1820

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// Ensures that TypeScript knows that the global "zone.js" types
10+
// are used in this source file.
11+
/// <reference types="zone.js" />
12+
13+
import {BehaviorSubject, Observable} from 'rxjs';
14+
15+
export interface TaskState {
16+
stable: boolean;
17+
}
18+
19+
export class TaskStateZoneInterceptor implements ZoneSpec {
20+
/** Name of the custom zone. */
21+
readonly name = 'cdk-testing-testbed-task-state-zone';
22+
23+
/** Public observable that emits whenever the task state changes. */
24+
readonly state: Observable<TaskState>;
25+
26+
/** Subject that can be used to emit a new state change. */
27+
private _stateSubject: BehaviorSubject<TaskState>;
28+
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+
}
34+
35+
/**
36+
* Implemented as part of "ZoneSpec". This will emit whenever the internal
37+
* ZoneJS task state changes.
38+
*/
39+
onHasTask(delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState) {
40+
delegate.hasTask(target, hasTaskState);
41+
if (current === target) {
42+
this._stateSubject.next(this._getTaskStateFromInternalZoneState(hasTaskState));
43+
}
44+
}
45+
46+
/** Gets the task state from the internal ZoneJS task state. */
47+
private _getTaskStateFromInternalZoneState(state: HasTaskState): TaskState {
48+
return {stable: !state.macroTask && !state.microTask};
49+
}
50+
}

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

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,28 @@
88

99
import {HarnessEnvironment} from '@angular/cdk/testing';
1010
import {ComponentFixture} from '@angular/core/testing';
11+
import {Observable} from 'rxjs';
12+
import {takeWhile} from 'rxjs/operators';
1113
import {ComponentHarness, ComponentHarnessConstructor, HarnessLoader} from '../component-harness';
1214
import {TestElement} from '../test-element';
15+
import {TaskState, TaskStateZoneInterceptor} from './task-state-zone-interceptor';
1316
import {UnitTestElement} from './unit-test-element';
17+
import {ProxyZoneType} from './zone-types';
18+
19+
// Ensures that TypeScript knows that the global "zone.js" types
20+
// are used in this source file.
21+
/// <reference types="zone.js" />
1422

1523
/** A `HarnessEnvironment` implementation for Angular's Testbed. */
1624
export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
1725
private _destroyed = false;
1826

27+
/** Observable that emits whenever the test task state changes. */
28+
private _taskState: Observable<TaskState>;
29+
1930
protected constructor(rawRootElement: Element, private _fixture: ComponentFixture<unknown>) {
2031
super(rawRootElement);
32+
this._taskState = this._setupZoneTaskStateInterceptor();
2133
_fixture.componentRef.onDestroy(() => this._destroyed = true);
2234
}
2335

@@ -53,7 +65,12 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
5365
}
5466

5567
this._fixture.detectChanges();
56-
await this._fixture.whenStable();
68+
69+
// Wait until the task queue has been drained and the zone is stable. Note that
70+
// we cannot rely on "fixture.whenStable" since it does not catch tasks scheduled
71+
// outside of the Angular zone. For test harnesses, we want to ensure that the
72+
// app is fully stabilized and therefore need to use our own zone interceptor.
73+
await this._taskState.pipe(takeWhile(state => !state.stable)).toPromise();
5774
}
5875

5976
protected getDocumentRoot(): Element {
@@ -72,4 +89,53 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
7289
await this.forceStabilize();
7390
return Array.from(this.rawRootElement.querySelectorAll(selector));
7491
}
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+
}
75141
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// Ensures that TypeScript knows that the global "zone.js" types
10+
// are used in this source file.
11+
/// <reference types="zone.js" />
12+
13+
export interface ProxyZoneType {
14+
assertPresent: () => void;
15+
get(): ProxyZone;
16+
}
17+
18+
export interface ProxyZone {
19+
lastTaskState: HasTaskState|null;
20+
setDelegate(spec: ZoneSpec): void;
21+
getDelegate(): ZoneSpec;
22+
}

0 commit comments

Comments
 (0)