Skip to content

Commit

Permalink
feat(cdk/testing): add method to wait for async tasks outside angular…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
devversion committed Oct 19, 2019
1 parent 2f197cd commit 48daa8f
Show file tree
Hide file tree
Showing 13 changed files with 267 additions and 10 deletions.
20 changes: 17 additions & 3 deletions src/cdk/testing/component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export interface HarnessLoader {
/**
* Interface used to create asynchronous locator functions used find elements and component
* harnesses. This interface is used by `ComponentHarness` authors to create locator functions for
* their `ComponentHarenss` subclass.
* their `ComponentHarness` subclass.
*/
export interface LocatorFactory {
/** Gets a locator factory rooted at the document root. */
Expand Down Expand Up @@ -167,11 +167,17 @@ export interface LocatorFactory {
harnessLoaderForAll(selector: string): Promise<HarnessLoader[]>;

/**
* Flushes change detection and async tasks.
* Flushes change detection and async tasks captured in the Angular zone.
* In most cases it should not be necessary to call this manually. However, there may be some edge
* cases where it is needed to fully flush animation events.
*/
forceStabilize(): Promise<void>;

/**
* Waits for all scheduled or running async tasks to complete. This allows harness
* authors to wait for async tasks outside of the Angular zone.
*/
waitForTasksOutsideAngular(): Promise<void>;
}

/**
Expand Down Expand Up @@ -277,13 +283,21 @@ export abstract class ComponentHarness {
}

/**
* Flushes change detection and async tasks.
* Flushes change detection and async tasks in the Angular zone.
* In most cases it should not be necessary to call this manually. However, there may be some edge
* cases where it is needed to fully flush animation events.
*/
protected async forceStabilize() {
return this.locatorFactory.forceStabilize();
}

/**
* Waits for all scheduled or running async tasks to complete. This allows harness
* authors to wait for async tasks outside of the Angular zone.
*/
protected async waitForTasksOutsideAngular() {
return this.locatorFactory.waitForTasksOutsideAngular();
}
}

/** Constructor for a ComponentHarness subclass. */
Expand Down
3 changes: 3 additions & 0 deletions src/cdk/testing/harness-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
// Part of LocatorFactory interface, subclasses will implement.
abstract forceStabilize(): Promise<void>;

// Part of LocatorFactory interface, subclasses will implement.
abstract waitForTasksOutsideAngular(): Promise<void>;

/** Gets the root element for the document. */
protected abstract getDocumentRoot(): E;

Expand Down
5 changes: 5 additions & 0 deletions src/cdk/testing/protractor/protractor-harness-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export class ProtractorHarnessEnvironment extends HarnessEnvironment<ElementFind

async forceStabilize(): Promise<void> {}

async waitForTasksOutsideAngular(): Promise<void> {
// TODO: figure out how we can do this for the protractor environment.
// https://github.com/angular/components/issues/17412
}

protected getDocumentRoot(): ElementFinder {
return protractorElement(by.css('body'));
}
Expand Down
1 change: 1 addition & 0 deletions src/cdk/testing/testbed/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ ts_library(
"//src/cdk/keycodes",
"//src/cdk/testing",
"@npm//@angular/core",
"@npm//rxjs",
],
)

Expand Down
31 changes: 31 additions & 0 deletions src/cdk/testing/testbed/proxy-zone-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

/*
* Type definitions of the "ProxyZone" implementation provided by the
* ZoneJS testing bundles. These types are not part of the default ZoneJS
* typings, so we need to replicate them here. Usually they would go into
* the "zone-types.d.ts" file where other types are brought in as well, but
* since internally in Google, the original zone.js types will be used, there
* needs to be a separation of types which are replicated or the ones that can
* be pulled in from the original type definitions.
*/

import {HasTaskState, Zone, ZoneDelegate} from './zone-types';

export interface ProxyZoneStatic {
assertPresent: () => ProxyZone;
get(): ProxyZone;
}

export interface ProxyZone {
lastTaskState: HasTaskState|null;
setDelegate(spec: ZoneSpec): void;
getDelegate(): ZoneSpec;
onHasTask(delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState): void;
}
109 changes: 109 additions & 0 deletions src/cdk/testing/testbed/task-state-zone-interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {BehaviorSubject, Observable} from 'rxjs';
import {ProxyZone, ProxyZoneStatic} from './proxy-zone-types';
import {HasTaskState, Zone, ZoneDelegate} from './zone-types';

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

/** Unique symbol that is used to patch a property to a proxy zone. */
const stateObservableSymbol = Symbol('ProxyZone_PATCHED#stateObservable');

/** Type that describes a potentially patched proxy zone instance. */
type PatchedProxyZone = ProxyZone & {
[stateObservableSymbol]: undefined|Observable<TaskState>;
};

/**
* Interceptor that can be set up in a `ProxyZone` instance. The interceptor
* will keep track of the task state and emit whenever the state changes.
*
* This serves as a workaround for https://github.com/angular/angular/issues/32896.
*/
export class TaskStateZoneInterceptor {
/** Subject that can be used to emit a new state change. */
private _stateSubject: BehaviorSubject<TaskState> = new BehaviorSubject<TaskState>(
this._lastState ? this._getTaskStateFromInternalZoneState(this._lastState) : {stable: true});

/** Public observable that emits whenever the task state changes. */
readonly state: Observable<TaskState> = this._stateSubject.asObservable();

constructor(private _lastState: HasTaskState|null) {}

/** This will be called whenever the task state changes in the intercepted zone. */
onHasTask(delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState) {
if (current === target) {
this._stateSubject.next(this._getTaskStateFromInternalZoneState(hasTaskState));
}
}

/** Gets the task state from the internal ZoneJS task state. */
private _getTaskStateFromInternalZoneState(state: HasTaskState): TaskState {
return {stable: !state.macroTask && !state.microTask};
}

/**
* Sets up the custom task state Zone interceptor in the `ProxyZone`. Throws if
* no `ProxyZone` could be found.
* @returns an observable that emits whenever the task state changes.
*/
static setup(): Observable<TaskState> {
if (Zone === undefined) {
throw Error('Could not find ZoneJS. For test harnesses running in TestBed, ' +
'ZoneJS needs to be installed.');
}

// tslint:disable-next-line:variable-name
const ProxyZoneSpec = (Zone as any)['ProxyZoneSpec'] as ProxyZoneStatic|undefined;

// If there is no "ProxyZoneSpec" installed, we throw an error and recommend
// setting up the proxy zone by pulling in the testing bundle.
if (!ProxyZoneSpec) {
throw Error(
'ProxyZoneSpec is needed for the test harnesses but could not be found. ' +
'Please make sure that your environment includes zone.js/dist/zone-testing.js');
}

// Ensure that there is a proxy zone instance set up, and get
// a reference to the instance if present.
const zoneSpec = ProxyZoneSpec.assertPresent() as PatchedProxyZone;

// If there already is a delegate registered in the proxy zone, and it
// is type of the custom task state interceptor, we just use that state
// observable. This allows us to only intercept Zone once per test
// (similar to how `fakeAsync` or `async` work).
if (zoneSpec[stateObservableSymbol]) {
return zoneSpec[stateObservableSymbol]!;
}

// Since we intercept on environment creation and the fixture has been
// created before, we might have missed tasks scheduled before. Fortunately
// the proxy zone keeps track of the previous task state, so we can just pass
// this as initial state to the task zone interceptor.
const interceptor = new TaskStateZoneInterceptor(zoneSpec.lastTaskState);
const zoneSpecOnHasTask = zoneSpec.onHasTask;

// We setup the task state interceptor in the `ProxyZone`. Note that we cannot register
// the interceptor as a new proxy zone delegate because it would mean that other zone
// delegates (e.g. `FakeAsyncTestZone` or `AsyncTestZone`) can accidentally overwrite/disable
// our interceptor. Since we just intend to monitor the task state of the proxy zone, it is
// sufficient to just patch the proxy zone. This also avoids that we interfere with the task
// queue scheduling logic.
zoneSpec.onHasTask = function() {
zoneSpecOnHasTask.apply(zoneSpec, arguments);
interceptor.onHasTask.apply(interceptor, arguments);
};

return zoneSpec[stateObservableSymbol] = interceptor.state;
}
}
28 changes: 27 additions & 1 deletion src/cdk/testing/testbed/testbed-harness-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,25 @@
*/

import {HarnessEnvironment} from '@angular/cdk/testing';
import {ComponentFixture} from '@angular/core/testing';
import {ComponentFixture, flush} from '@angular/core/testing';
import {Observable} from 'rxjs';
import {takeWhile} from 'rxjs/operators';
import {ComponentHarness, ComponentHarnessConstructor, HarnessLoader} from '../component-harness';
import {TestElement} from '../test-element';
import {TaskState, TaskStateZoneInterceptor} from './task-state-zone-interceptor';
import {UnitTestElement} from './unit-test-element';


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

/** Observable that emits whenever the test task state changes. */
private _taskState: Observable<TaskState>;

protected constructor(rawRootElement: Element, private _fixture: ComponentFixture<unknown>) {
super(rawRootElement);
this._taskState = TaskStateZoneInterceptor.setup();
_fixture.componentRef.onDestroy(() => this._destroyed = true);
}

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

async waitForTasksOutsideAngular(): Promise<void> {
// If we run in the fake async zone, we run "flush" to run any scheduled tasks. This
// ensures that the harnesses behave inside of the FakeAsyncTestZone similar to the
// "AsyncTestZone" and the root zone (i.e. neither fakeAsync or async). Note that we
// cannot just rely on the task state observable to become stable because the state will
// never change. This is because the task queue will be only drained if the fake async
// zone is being flushed.
if (Zone!.current.get('FakeAsyncTestZoneSpec')) {
flush();
}

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

protected getDocumentRoot(): Element {
return document.body;
}
Expand Down
24 changes: 24 additions & 0 deletions src/cdk/testing/testbed/zone-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

/*
* Type definitions for "zone.js". We cannot reference the official types
* using a triple-slash types directive because the types would bring in
* the NodeJS types into the compilation unit. This would cause unexpected
* type checking failures. We just create minimal type definitions for Zone
* here and use these for our interceptor logic.
*/

declare global {
// tslint:disable-next-line:variable-name
const Zone: {current: any}|undefined;
}

export type Zone = Object;
export type ZoneDelegate = Object;
export type HasTaskState = {microTask: boolean, macroTask: boolean};
11 changes: 11 additions & 0 deletions src/cdk/testing/tests/harnesses/main-component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export class MainComponentHarness extends ComponentHarness {
readonly optionalSubComponent = this.locatorForOptional(SubComponentHarness);
readonly errorSubComponent = this.locatorFor(WrongComponentHarness);

readonly taskStateTestTrigger = this.locatorFor('#task-state-test-trigger');
readonly taskStateTestResult = this.locatorFor('#task-state-result');

readonly fourItemLists = this.locatorForAll(SubComponentHarness.with({itemCount: 4}));
readonly toolsLists = this.locatorForAll(SubComponentHarness.with({title: 'List of test tools'}));
readonly fourItemToolsLists =
Expand Down Expand Up @@ -96,4 +99,12 @@ export class MainComponentHarness extends ComponentHarness {
async sendAltJ(): Promise<void> {
return (await this.input()).sendKeys({alt: true}, 'j');
}

async getTaskStateResult(): Promise<string> {
await (await this.taskStateTestTrigger()).click();
// Wait for async tasks to complete since the click caused a
// timeout to be scheduled outside of the NgZone.
await this.waitForTasksOutsideAngular();
return (await this.taskStateTestResult()).text();
}
}
7 changes: 7 additions & 0 deletions src/cdk/testing/tests/test-main-component.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@ <h1 style="height: 100px; width: 200px;">Main Component</h1>
<test-sub title="other 1"></test-sub>
<test-sub title="other 2"></test-sub>
</div>
<div class="task-state-tests">
<button (click)="runTaskOutsideZone()" id="task-state-test-trigger">
Run task outside zone
</button>
<span id="task-state-result" #taskStateResult></span>
</div>

10 changes: 9 additions & 1 deletion src/cdk/testing/tests/test-main-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ChangeDetectorRef,
Component,
ElementRef,
NgZone,
OnDestroy,
ViewChild,
ViewEncapsulation
Expand Down Expand Up @@ -44,6 +45,7 @@ export class TestMainComponent implements OnDestroy {
relativeY = 0;

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

private _fakeOverlayElement: HTMLElement;

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

constructor(private _cdr: ChangeDetectorRef) {
constructor(private _cdr: ChangeDetectorRef, private _zone: NgZone) {
this.username = 'Yi';
this.counter = 0;
this.asyncCounter = 0;
Expand Down Expand Up @@ -99,4 +101,10 @@ export class TestMainComponent implements OnDestroy {
this.relativeX = Math.round(event.clientX - left);
this.relativeY = Math.round(event.clientY - top);
}

runTaskOutsideZone() {
this._zone.runOutsideAngular(() => setTimeout(() => {
this.taskStateResultElement.nativeElement.textContent = 'result';
}, 100));
}
}
17 changes: 16 additions & 1 deletion src/cdk/testing/tests/testbed.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {async, ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing';
import {FakeOverlayHarness} from './harnesses/fake-overlay-harness';
import {MainComponentHarness} from './harnesses/main-component-harness';
import {SubComponentHarness} from './harnesses/sub-component-harness';
Expand Down Expand Up @@ -252,6 +252,21 @@ describe('TestbedHarnessEnvironment', () => {
const subcomps = await harness.directAncestorSelectorSubcomponent();
expect(subcomps.length).toBe(2);
});

it('should be able to wait for tasks outside of Angular within native async/await',
async () => {
expect(await harness.getTaskStateResult()).toBe('result');
});

it('should be able to wait for tasks outside of Angular within async test zone',
async (() => {
harness.getTaskStateResult().then(res => expect(res).toBe('result'));
}));

it('should be able to wait for tasks outside of Angular within fakeAsync test zone',
fakeAsync(async () => {
expect(await harness.getTaskStateResult()).toBe('result');
}));
});

describe('TestElement', () => {
Expand Down
Loading

0 comments on commit 48daa8f

Please sign in to comment.