Skip to content

Commit

Permalink
feat(ng-dev): AngularContext supports component harnesses with fake…
Browse files Browse the repository at this point in the history
…Async

BREAKING CHANGE: requires @angular/cdk as a peer dependency
  • Loading branch information
ersimont committed Nov 12, 2020
1 parent 7cfec64 commit 97a0fb9
Show file tree
Hide file tree
Showing 10 changed files with 274 additions and 2 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.1002.0",
"@angular/cdk": "~10.2.7",
"@angular/cli": "~10.2.0",
"@angular/compiler-cli": "~10.2.2",
"@angular/material": "~10.2.7",
"@types/jasmine": "~3.5.0",
"@types/jasminewd2": "~2.0.3",
"@types/lodash-es": "^4.17.3",
Expand Down
2 changes: 1 addition & 1 deletion projects/ng-dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ To quickly see what is available, see the [api documentation](https://simontonso
## Installation

```
yarn add -D @s-libs/ng-dev @s-libs/ng-core @s-libs/rxjs-core @s-libs/js-core @s-libs/micro-dash
yarn add -D @s-libs/ng-dev @s-libs/ng-core @s-libs/rxjs-core @s-libs/js-core @s-libs/micro-dash @angular/cdk
```

## TSLint Config
Expand Down
1 change: 1 addition & 0 deletions projects/ng-dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"directory": "projects/ng-dev"
},
"peerDependencies": {
"@angular/cdk": "^10.2.0",
"@angular/common": "^10.2.0",
"@angular/core": "^10.2.0",
"@s-libs/js-core": "^0.2.0",
Expand Down
52 changes: 51 additions & 1 deletion projects/ng-dev/src/lib/test-context/angular-context.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { HttpClient } from '@angular/common/http';
import { HttpTestingController } from '@angular/common/http/testing';
import {
Expand All @@ -10,14 +11,29 @@ import {
Injector,
} from '@angular/core';
import { TestBed, tick } from '@angular/core/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatSnackBarHarness } from '@angular/material/snack-bar/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { noop, Observable } from 'rxjs';
import { expectSingleCallAndReset } from '../spies';
import { AngularContext } from './angular-context';

class TestContext extends AngularContext {
constructor() {
super({ imports: [MatSnackBarModule, NoopAnimationsModule] });
}

protected cleanUp(): void {
this.inject(OverlayContainer).ngOnDestroy();
this.tick(5000);
super.cleanUp();
}
}

describe('AngularContext', () => {
let ctx: AngularContext;
beforeEach(() => {
ctx = new AngularContext();
ctx = new TestContext();
});

describe('.startTime', () => {
Expand Down Expand Up @@ -78,6 +94,40 @@ describe('AngularContext', () => {
});
});

describe('.getHarness()', () => {
it('returns a synchronized harness', () => {
ctx.run(() => {
ctx.inject(MatSnackBar).open('hi');
const bar = ctx.getHarness(MatSnackBarHarness);
expect(bar.getMessage()).toBe('hi');
});
});
});

describe('.getHarnessForOptional()', () => {
it('gets either the a synchronized harness or null', () => {
ctx.run(() => {
expect(ctx.getHarnessForOptional(MatSnackBarHarness)).toBeNull();
ctx.inject(MatSnackBar).open('hi');
const bar = ctx.getHarnessForOptional(MatSnackBarHarness);
expect(bar).not.toBeNull();
expect(bar!.getMessage()).toBe('hi');
});
});
});

describe('.getAllHarnesses()', () => {
it('gets an array of synchronized harnesses', () => {
ctx.run(() => {
expect(ctx.getAllHarnesses(MatSnackBarHarness).length).toBe(0);
ctx.inject(MatSnackBar).open('hi');
const bars = ctx.getAllHarnesses(MatSnackBarHarness);
expect(bars.length).toBe(1);
expect(bars[0].getMessage()).toBe('hi');
});
});
});

describe('.tick()', () => {
it('defaults to not advance time', () => {
const start = ctx.startTime.getTime();
Expand Down
34 changes: 34 additions & 0 deletions projects/ng-dev/src/lib/test-context/angular-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ComponentHarness, HarnessQuery } from '@angular/cdk/testing';
import {
HttpClientTestingModule,
HttpTestingController,
Expand All @@ -18,6 +19,8 @@ import {
} from '@angular/core/testing';
import { assert, convertTime } from '@s-libs/js-core';
import { clone, forOwn, isFunction } from '@s-libs/micro-dash';
import { FakeAsyncHarnessEnvironment } from './fake-async-harness-environment';
import { Synchronized } from './synchronize';

/** @hidden */
export function extendMetadata(
Expand Down Expand Up @@ -92,6 +95,8 @@ export class AngularContext<InitOptions = {}> {
*/
startTime = new Date();

private loader = FakeAsyncHarnessEnvironment.documentRootLoader(this);

/**
* @param moduleMetadata passed along to [TestBed.configureTestingModule()]{@linkcode https://angular.io/api/core/testing/TestBed#configureTestingModule}. Automatically includes {@link HttpClientTestingModule} for you.
*/
Expand Down Expand Up @@ -146,6 +151,35 @@ export class AngularContext<InitOptions = {}> {
return TestBed.inject(token);
}

/**
* Gets a component harness, wrapped for use in a fakeAsync test so that you do not need to `await` its results. Throws an error if no match can be located.
*/
getHarness<T extends ComponentHarness>(
query: HarnessQuery<T>,
): Synchronized<T> {
return this.loader.getHarness(query) as Synchronized<T>;
}

/**
* Gets a component harness, wrapped for use in a fakeAsync test so that you do not need to `await` its results. Returns `null` if the harness cannot be located.
*/
getHarnessForOptional<T extends ComponentHarness>(
query: HarnessQuery<T>,
): Synchronized<T> | null {
return this.loader.locatorForOptional(query)() as Synchronized<T> | null;
}

/**
* Gets all component harnesses that match the query, wrapped for use in a fakeAsync test so that you do not need to `await` its results.
*/
getAllHarnesses<T extends ComponentHarness>(
query: HarnessQuery<T>,
): Array<Synchronized<T>> {
return (this.loader.getAllHarnesses(query) as unknown) as Array<
Synchronized<T>
>;
}

/**
* Advance time and trigger change detection. It is common to call this with no arguments to trigger change detection without advancing time.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ContentContainerComponentHarness } from '@angular/cdk/testing';
import { Component } from '@angular/core';
import { MatButtonHarness } from '@angular/material/button/testing';
import { ComponentContext } from './component-context';
import { Synchronized } from './synchronize';

@Component({
selector: 's-test-component',
template: `
<div class="wrapper">
<button mat-button (click)="clicked = true">{{ clicked }}</button>
</div>
`,
})
class TestComponent {
clicked = false;
}

class WrapperHarness extends ContentContainerComponentHarness {
static hostSelector = '.wrapper';
}

class TestContext extends ComponentContext {
protected componentType = TestComponent;
}

describe('FakeAsyncHarnessEnvironment', () => {
let ctx: TestContext;
beforeEach(() => {
ctx = new TestContext();
});

it('runs asynchronous events that are due automatically', () => {
ctx.run(() => {
const button = ctx.getHarness(MatButtonHarness);
button.click();
expect(button.getText()).toBe('true');
});
});

it('locates sub harnesses that are also synchronized', () => {
ctx.run(() => {
const button = ctx
.getHarness(WrapperHarness)
.getHarness(MatButtonHarness) as Synchronized<MatButtonHarness>;
button.click();
expect(button.getText()).toBe('true');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { HarnessEnvironment } from '@angular/cdk/testing';
import { UnitTestElement } from '@angular/cdk/testing/testbed';
import { flush } from '@angular/core/testing';
import { bindKey } from '@s-libs/micro-dash';
import { AngularContext } from './angular-context';
import { synchronize, Synchronized } from './synchronize';

/** @hidden */
export class FakeAsyncHarnessEnvironment extends HarnessEnvironment<Element> {
static documentRootLoader(
ctx: AngularContext,
): Synchronized<FakeAsyncHarnessEnvironment> {
return synchronize(new FakeAsyncHarnessEnvironment(document.body, ctx));
}

protected constructor(rawRootElement: Element, private ctx: AngularContext) {
super(rawRootElement);
}

async waitForTasksOutsideAngular(): Promise<void> {
flush();
}

async forceStabilize(): Promise<void> {
this.ctx.tick();
}

protected createEnvironment(element: Element): HarnessEnvironment<Element> {
return new FakeAsyncHarnessEnvironment(element, this.ctx);
}

protected createTestElement(element: Element): UnitTestElement {
return new UnitTestElement(element, bindKey(this, 'forceStabilize'));
}

protected async getAllRawElements(selector: string): Promise<Element[]> {
return Array.from(this.rawRootElement.querySelectorAll(selector));
}

protected getDocumentRoot(): Element {
return document.body;
}
}
1 change: 1 addition & 0 deletions projects/ng-dev/src/lib/test-context/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { AngularContext } from './angular-context';
export { ComponentContext } from './component-context';
export { Synchronized } from './synchronize';
70 changes: 70 additions & 0 deletions projects/ng-dev/src/lib/test-context/synchronize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { tick } from '@angular/core/testing';
import { isPromiseLike } from '@s-libs/js-core';
import { get, isObject } from '@s-libs/micro-dash';

/**
* The type for component harness used in an `AngularContext`, which wraps the original harness in a proxy meant to be used in a `fakeAsync` test where one cannot `await` the results of an asynchronous call. The functions that would normally return a promise instead return their result immediately, calling `tick()` to flush it first.
*/
export type Synchronized<O extends object> = {
[K in keyof O]: O[K] extends (...args: any[]) => any
? SynchronizedFunction<O[K]>
: O[K];
};

/** @hidden */
type SynchronizedFunction<F extends (...args: any[]) => any> = (
...args: Parameters<F>
) => SynchronizedResult<ReturnType<F>>;
/** @hidden */
type SynchronizedResult<V> = V extends PromiseLike<any>
? SynchronizedPromise<V>
: V extends object
? Synchronized<V>
: V;
/** @hidden */
type SynchronizedPromise<P> = P extends PromiseLike<infer R>
? R extends object
? Synchronized<R>
: R
: 'assertion error';

/** @hidden */
const proxyTarget = Symbol('proxy target'); // trick from https://stackoverflow.com/a/53431924/1836506
/** @hidden */
export function synchronize<T extends object>(obj: T): Synchronized<T> {
return new Proxy(obj, {
get(target, p, receiver): any {
if (p === proxyTarget) {
return obj;
} else {
return synchronizeAny(Reflect.get(target, p, receiver));
}
},
apply(target, thisArg, argArray): any {
thisArg = get(thisArg, [proxyTarget], thisArg);
return synchronizeAny(
Reflect.apply(target as Function, thisArg, argArray),
);
},
}) as Synchronized<T>;
}

/** @hidden */
function synchronizeAny(value: any): any {
if (isPromiseLike(value)) {
return synchronizePromise(value);
} else if (isObject(value)) {
return synchronize(value);
} else {
return value;
}
}

/** @hidden */
function synchronizePromise<T extends PromiseLike<any>>(promise: T): any {
let awaited: any;
promise.then((value) => (awaited = value));
tick();
// TODO: a nice error if the promise didn't resolve
return synchronizeAny(awaited);
}
21 changes: 21 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@
dependencies:
tslib "^2.0.0"

"@angular/cdk@~10.2.7":
version "10.2.7"
resolved "https://registry.npmjs.org/@angular/cdk/-/cdk-10.2.7.tgz#0ff82eb91b2653ea26909c57a460d4593b44f186"
integrity sha512-ZQjDfTRTn7JuAKsf3jiIdU2XBaxxGBi/ZWYv5Pb3HCl6B4PISsIE5VWRhkoUogoAB0MiFHpjnWeIqknJEm11YQ==
dependencies:
tslib "^2.0.0"
optionalDependencies:
parse5 "^5.0.0"

"@angular/cli@~10.2.0":
version "10.2.0"
resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-10.2.0.tgz#b0b465120eb9a39e5efd030bf80c023c630960ed"
Expand Down Expand Up @@ -211,6 +220,13 @@
dependencies:
tslib "^2.0.0"

"@angular/material@^10.2.7":
version "10.2.7"
resolved "https://registry.npmjs.org/@angular/material/-/material-10.2.7.tgz#b84aeeca997dc64fbcc7969540e22434315bab68"
integrity sha512-uk6JkRrKHaM9VFMzX7pWC83YNLVgXPB3D8U1yjSOafCdWwrRZgUHGr8MPlSILCr3o2nxgg5SsKdWcWwHuXXUZA==
dependencies:
tslib "^2.0.0"

"@angular/platform-browser-dynamic@~10.2.2":
version "10.2.2"
resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-10.2.2.tgz#eed43065e884de9f355fd5ad300e433c5b58dbd8"
Expand Down Expand Up @@ -7852,6 +7868,11 @@ parse5@6.0.1, parse5@^6.0.1:
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==

parse5@^5.0.0:
version "5.1.1"
resolved "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==

parseqs@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
Expand Down

0 comments on commit 97a0fb9

Please sign in to comment.