Skip to content

feat(cdk/testing): add method to wait for async tasks outside angular to complete #17408

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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