Skip to content

Commit 48daa8f

Browse files
committed
feat(cdk/testing): add method to wait for async tasks outside angular to complete
Adds a new method to the `ComponentHarness` base class that can be used to wait for async tasks to complete. This is useful for harness authors as sometimes the component schedules tasks outside of the Angular zone which therefore won't be captured by `whenStable`. These tasks can be relevant for the harness logic though, so there should be an option for harness authors to await _all_ async tasks (not only the ones captured in the ng zone). e.g. the slider re-adjusts when the directionality changes. This re-rendering happens in the next tick. To make sure that `setValue` works when the directionality changed, we need to wait for the async tasks to complete, so that the slider completed re-rendering and the value can be changed through a mouse click.
1 parent 2f197cd commit 48daa8f

13 files changed

+267
-10
lines changed

src/cdk/testing/component-harness.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export interface HarnessLoader {
6666
/**
6767
* Interface used to create asynchronous locator functions used find elements and component
6868
* harnesses. This interface is used by `ComponentHarness` authors to create locator functions for
69-
* their `ComponentHarenss` subclass.
69+
* their `ComponentHarness` subclass.
7070
*/
7171
export interface LocatorFactory {
7272
/** Gets a locator factory rooted at the document root. */
@@ -167,11 +167,17 @@ export interface LocatorFactory {
167167
harnessLoaderForAll(selector: string): Promise<HarnessLoader[]>;
168168

169169
/**
170-
* Flushes change detection and async tasks.
170+
* Flushes change detection and async tasks captured in the Angular zone.
171171
* In most cases it should not be necessary to call this manually. However, there may be some edge
172172
* cases where it is needed to fully flush animation events.
173173
*/
174174
forceStabilize(): Promise<void>;
175+
176+
/**
177+
* Waits for all scheduled or running async tasks to complete. This allows harness
178+
* authors to wait for async tasks outside of the Angular zone.
179+
*/
180+
waitForTasksOutsideAngular(): Promise<void>;
175181
}
176182

177183
/**
@@ -277,13 +283,21 @@ export abstract class ComponentHarness {
277283
}
278284

279285
/**
280-
* Flushes change detection and async tasks.
286+
* Flushes change detection and async tasks in the Angular zone.
281287
* In most cases it should not be necessary to call this manually. However, there may be some edge
282288
* cases where it is needed to fully flush animation events.
283289
*/
284290
protected async forceStabilize() {
285291
return this.locatorFactory.forceStabilize();
286292
}
293+
294+
/**
295+
* Waits for all scheduled or running async tasks to complete. This allows harness
296+
* authors to wait for async tasks outside of the Angular zone.
297+
*/
298+
protected async waitForTasksOutsideAngular() {
299+
return this.locatorFactory.waitForTasksOutsideAngular();
300+
}
287301
}
288302

289303
/** Constructor for a ComponentHarness subclass. */

src/cdk/testing/harness-environment.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
130130
// Part of LocatorFactory interface, subclasses will implement.
131131
abstract forceStabilize(): Promise<void>;
132132

133+
// Part of LocatorFactory interface, subclasses will implement.
134+
abstract waitForTasksOutsideAngular(): Promise<void>;
135+
133136
/** Gets the root element for the document. */
134137
protected abstract getDocumentRoot(): E;
135138

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export class ProtractorHarnessEnvironment extends HarnessEnvironment<ElementFind
2525

2626
async forceStabilize(): Promise<void> {}
2727

28+
async waitForTasksOutsideAngular(): Promise<void> {
29+
// TODO: figure out how we can do this for the protractor environment.
30+
// https://github.com/angular/components/issues/17412
31+
}
32+
2833
protected getDocumentRoot(): ElementFinder {
2934
return protractorElement(by.css('body'));
3035
}

src/cdk/testing/testbed/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ ts_library(
1313
"//src/cdk/keycodes",
1414
"//src/cdk/testing",
1515
"@npm//@angular/core",
16+
"@npm//rxjs",
1617
],
1718
)
1819

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
/*
10+
* Type definitions of the "ProxyZone" implementation provided by the
11+
* ZoneJS testing bundles. These types are not part of the default ZoneJS
12+
* typings, so we need to replicate them here. Usually they would go into
13+
* the "zone-types.d.ts" file where other types are brought in as well, but
14+
* since internally in Google, the original zone.js types will be used, there
15+
* needs to be a separation of types which are replicated or the ones that can
16+
* be pulled in from the original type definitions.
17+
*/
18+
19+
import {HasTaskState, Zone, ZoneDelegate} from './zone-types';
20+
21+
export interface ProxyZoneStatic {
22+
assertPresent: () => ProxyZone;
23+
get(): ProxyZone;
24+
}
25+
26+
export interface ProxyZone {
27+
lastTaskState: HasTaskState|null;
28+
setDelegate(spec: ZoneSpec): void;
29+
getDelegate(): ZoneSpec;
30+
onHasTask(delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState): void;
31+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
import {BehaviorSubject, Observable} from 'rxjs';
10+
import {ProxyZone, ProxyZoneStatic} from './proxy-zone-types';
11+
import {HasTaskState, Zone, ZoneDelegate} from './zone-types';
12+
13+
/** Current state of the intercepted zone. */
14+
export interface TaskState {
15+
/** Whether the zone is stable (i.e. no microtasks and macrotasks). */
16+
stable: boolean;
17+
}
18+
19+
/** Unique symbol that is used to patch a property to a proxy zone. */
20+
const stateObservableSymbol = Symbol('ProxyZone_PATCHED#stateObservable');
21+
22+
/** Type that describes a potentially patched proxy zone instance. */
23+
type PatchedProxyZone = ProxyZone & {
24+
[stateObservableSymbol]: undefined|Observable<TaskState>;
25+
};
26+
27+
/**
28+
* Interceptor that can be set up in a `ProxyZone` instance. The interceptor
29+
* will keep track of the task state and emit whenever the state changes.
30+
*
31+
* This serves as a workaround for https://github.com/angular/angular/issues/32896.
32+
*/
33+
export class TaskStateZoneInterceptor {
34+
/** Subject that can be used to emit a new state change. */
35+
private _stateSubject: BehaviorSubject<TaskState> = new BehaviorSubject<TaskState>(
36+
this._lastState ? this._getTaskStateFromInternalZoneState(this._lastState) : {stable: true});
37+
38+
/** Public observable that emits whenever the task state changes. */
39+
readonly state: Observable<TaskState> = this._stateSubject.asObservable();
40+
41+
constructor(private _lastState: HasTaskState|null) {}
42+
43+
/** This will be called whenever the task state changes in the intercepted zone. */
44+
onHasTask(delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState) {
45+
if (current === target) {
46+
this._stateSubject.next(this._getTaskStateFromInternalZoneState(hasTaskState));
47+
}
48+
}
49+
50+
/** Gets the task state from the internal ZoneJS task state. */
51+
private _getTaskStateFromInternalZoneState(state: HasTaskState): TaskState {
52+
return {stable: !state.macroTask && !state.microTask};
53+
}
54+
55+
/**
56+
* Sets up the custom task state Zone interceptor in the `ProxyZone`. Throws if
57+
* no `ProxyZone` could be found.
58+
* @returns an observable that emits whenever the task state changes.
59+
*/
60+
static setup(): Observable<TaskState> {
61+
if (Zone === undefined) {
62+
throw Error('Could not find ZoneJS. For test harnesses running in TestBed, ' +
63+
'ZoneJS needs to be installed.');
64+
}
65+
66+
// tslint:disable-next-line:variable-name
67+
const ProxyZoneSpec = (Zone as any)['ProxyZoneSpec'] as ProxyZoneStatic|undefined;
68+
69+
// If there is no "ProxyZoneSpec" installed, we throw an error and recommend
70+
// setting up the proxy zone by pulling in the testing bundle.
71+
if (!ProxyZoneSpec) {
72+
throw Error(
73+
'ProxyZoneSpec is needed for the test harnesses but could not be found. ' +
74+
'Please make sure that your environment includes zone.js/dist/zone-testing.js');
75+
}
76+
77+
// Ensure that there is a proxy zone instance set up, and get
78+
// a reference to the instance if present.
79+
const zoneSpec = ProxyZoneSpec.assertPresent() as PatchedProxyZone;
80+
81+
// If there already is a delegate registered in the proxy zone, and it
82+
// is type of the custom task state interceptor, we just use that state
83+
// observable. This allows us to only intercept Zone once per test
84+
// (similar to how `fakeAsync` or `async` work).
85+
if (zoneSpec[stateObservableSymbol]) {
86+
return zoneSpec[stateObservableSymbol]!;
87+
}
88+
89+
// Since we intercept on environment creation and the fixture has been
90+
// created before, we might have missed tasks scheduled before. Fortunately
91+
// the proxy zone keeps track of the previous task state, so we can just pass
92+
// this as initial state to the task zone interceptor.
93+
const interceptor = new TaskStateZoneInterceptor(zoneSpec.lastTaskState);
94+
const zoneSpecOnHasTask = zoneSpec.onHasTask;
95+
96+
// We setup the task state interceptor in the `ProxyZone`. Note that we cannot register
97+
// the interceptor as a new proxy zone delegate because it would mean that other zone
98+
// delegates (e.g. `FakeAsyncTestZone` or `AsyncTestZone`) can accidentally overwrite/disable
99+
// our interceptor. Since we just intend to monitor the task state of the proxy zone, it is
100+
// sufficient to just patch the proxy zone. This also avoids that we interfere with the task
101+
// queue scheduling logic.
102+
zoneSpec.onHasTask = function() {
103+
zoneSpecOnHasTask.apply(zoneSpec, arguments);
104+
interceptor.onHasTask.apply(interceptor, arguments);
105+
};
106+
107+
return zoneSpec[stateObservableSymbol] = interceptor.state;
108+
}
109+
}

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,25 @@
77
*/
88

99
import {HarnessEnvironment} from '@angular/cdk/testing';
10-
import {ComponentFixture} from '@angular/core/testing';
10+
import {ComponentFixture, flush} 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';
1417

18+
1519
/** A `HarnessEnvironment` implementation for Angular's Testbed. */
1620
export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
1721
private _destroyed = false;
1822

23+
/** Observable that emits whenever the test task state changes. */
24+
private _taskState: Observable<TaskState>;
25+
1926
protected constructor(rawRootElement: Element, private _fixture: ComponentFixture<unknown>) {
2027
super(rawRootElement);
28+
this._taskState = TaskStateZoneInterceptor.setup();
2129
_fixture.componentRef.onDestroy(() => this._destroyed = true);
2230
}
2331

@@ -56,6 +64,24 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
5664
await this._fixture.whenStable();
5765
}
5866

67+
async waitForTasksOutsideAngular(): Promise<void> {
68+
// If we run in the fake async zone, we run "flush" to run any scheduled tasks. This
69+
// ensures that the harnesses behave inside of the FakeAsyncTestZone similar to the
70+
// "AsyncTestZone" and the root zone (i.e. neither fakeAsync or async). Note that we
71+
// cannot just rely on the task state observable to become stable because the state will
72+
// never change. This is because the task queue will be only drained if the fake async
73+
// zone is being flushed.
74+
if (Zone!.current.get('FakeAsyncTestZoneSpec')) {
75+
flush();
76+
}
77+
78+
// Wait until the task queue has been drained and the zone is stable. Note that
79+
// we cannot rely on "fixture.whenStable" since it does not catch tasks scheduled
80+
// outside of the Angular zone. For test harnesses, we want to ensure that the
81+
// app is fully stabilized and therefore need to use our own zone interceptor.
82+
await this._taskState.pipe(takeWhile(state => !state.stable)).toPromise();
83+
}
84+
5985
protected getDocumentRoot(): Element {
6086
return document.body;
6187
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
/*
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};

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

Lines changed: 11 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,12 @@ 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+
// Wait for async tasks to complete since the click caused a
106+
// timeout to be scheduled outside of the NgZone.
107+
await this.waitForTasksOutsideAngular();
108+
return (await this.taskStateTestResult()).text();
109+
}
99110
}

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 be able to wait for tasks outside of Angular within native async/await',
257+
async () => {
258+
expect(await harness.getTaskStateResult()).toBe('result');
259+
});
260+
261+
it('should be able to wait for tasks outside of Angular within async test zone',
262+
async (() => {
263+
harness.getTaskStateResult().then(res => expect(res).toBe('result'));
264+
}));
265+
266+
it('should be able to wait for tasks outside of Angular 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)