Skip to content

Commit 15a5171

Browse files
authored
feat(cdk/testing): support querying for multiple TestHarness /… (#17658)
* feat(cdk/testing): support querying for multiple TestHarness / ComponentHarness at once in locatorFor * fix lint and api goldens * address feedback * address feedback * address comments * address comments
1 parent 0b0e98c commit 15a5171

File tree

12 files changed

+668
-274
lines changed

12 files changed

+668
-274
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"dev-app": "ibazel run //src/dev-app:devserver",
2121
"test": "bazel test //src/... --test_tag_filters=-e2e,-browser:firefox-local --build_tag_filters=-browser:firefox-local --build_tests_only",
2222
"test-firefox": "bazel test //src/... --test_tag_filters=-e2e,-browser:chromium-local --build_tag_filters=-browser:chromium-local --build_tests_only",
23-
"lint": "gulp lint && yarn -s bazel:format-lint",
23+
"lint": "yarn -s tslint && yarn -s bazel:format-lint && yarn -s ownerslint",
2424
"e2e": "bazel test //src/... --test_tag_filters=e2e",
2525
"deploy": "echo 'Not supported yet. Tracked with COMP-230'",
2626
"webdriver-manager": "webdriver-manager",

src/cdk/testing/component-harness.ts

Lines changed: 224 additions & 136 deletions
Large diffs are not rendered by default.

src/cdk/testing/harness-environment.ts

Lines changed: 172 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,30 @@ import {
1212
ComponentHarnessConstructor,
1313
HarnessLoader,
1414
HarnessPredicate,
15-
LocatorFactory
15+
HarnessQuery,
16+
LocatorFactory,
17+
LocatorFnResult,
1618
} from './component-harness';
1719
import {TestElement} from './test-element';
1820

21+
/** Parsed form of the queries passed to the `locatorFor*` methods. */
22+
type ParsedQueries<T extends ComponentHarness> = {
23+
/** The full list of queries, in their original order. */
24+
allQueries: (string | HarnessPredicate<T>)[],
25+
/**
26+
* A filtered view of `allQueries` containing only the queries that are looking for a
27+
* `ComponentHarness`
28+
*/
29+
harnessQueries: HarnessPredicate<T>[],
30+
/**
31+
* A filtered view of `allQueries` containing only the queries that are looking for a
32+
* `TestElement`
33+
*/
34+
elementQueries: string[],
35+
/** The set of all `ComponentHarness` subclasses represented in the original query list. */
36+
harnessTypes: Set<ComponentHarnessConstructor<T>>,
37+
};
38+
1939
/**
2040
* Base harness environment class that can be extended to allow `ComponentHarness`es to be used in
2141
* different test environments (e.g. testbed, protractor, etc.). This class implements the
@@ -36,55 +56,29 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
3656
}
3757

3858
// Implemented as part of the `LocatorFactory` interface.
39-
locatorFor(selector: string): AsyncFactoryFn<TestElement>;
40-
locatorFor<T extends ComponentHarness>(
41-
harnessType: ComponentHarnessConstructor<T> | HarnessPredicate<T>): AsyncFactoryFn<T>;
42-
locatorFor<T extends ComponentHarness>(
43-
arg: string | ComponentHarnessConstructor<T> | HarnessPredicate<T>) {
44-
return async () => {
45-
if (typeof arg === 'string') {
46-
return this.createTestElement(await this._assertElementFound(arg));
47-
} else {
48-
return this._assertHarnessFound(arg);
49-
}
50-
};
59+
locatorFor<T extends (HarnessQuery<any> | string)[]>(...queries: T):
60+
AsyncFactoryFn<LocatorFnResult<T>> {
61+
return () => _assertResultFound(
62+
this._getAllHarnessesAndTestElements(queries),
63+
_getDescriptionForLocatorForQueries(queries));
5164
}
5265

5366
// Implemented as part of the `LocatorFactory` interface.
54-
locatorForOptional(selector: string): AsyncFactoryFn<TestElement | null>;
55-
locatorForOptional<T extends ComponentHarness>(
56-
harnessType: ComponentHarnessConstructor<T> | HarnessPredicate<T>): AsyncFactoryFn<T | null>;
57-
locatorForOptional<T extends ComponentHarness>(
58-
arg: string | ComponentHarnessConstructor<T> | HarnessPredicate<T>) {
59-
return async () => {
60-
if (typeof arg === 'string') {
61-
const element = (await this.getAllRawElements(arg))[0];
62-
return element ? this.createTestElement(element) : null;
63-
} else {
64-
const candidates = await this._getAllHarnesses(arg);
65-
return candidates[0] || null;
66-
}
67-
};
67+
locatorForOptional<T extends (HarnessQuery<any> | string)[]>(...queries: T):
68+
AsyncFactoryFn<LocatorFnResult<T> | null> {
69+
return async () => (await this._getAllHarnessesAndTestElements(queries))[0] || null;
6870
}
6971

7072
// Implemented as part of the `LocatorFactory` interface.
71-
locatorForAll(selector: string): AsyncFactoryFn<TestElement[]>;
72-
locatorForAll<T extends ComponentHarness>(
73-
harnessType: ComponentHarnessConstructor<T> | HarnessPredicate<T>): AsyncFactoryFn<T[]>;
74-
locatorForAll<T extends ComponentHarness>(
75-
arg: string | ComponentHarnessConstructor<T> | HarnessPredicate<T>) {
76-
return async () => {
77-
if (typeof arg === 'string') {
78-
return (await this.getAllRawElements(arg)).map(e => this.createTestElement(e));
79-
} else {
80-
return this._getAllHarnesses(arg);
81-
}
82-
};
73+
locatorForAll<T extends (HarnessQuery<any> | string)[]>(...queries: T):
74+
AsyncFactoryFn<LocatorFnResult<T>[]> {
75+
return () => this._getAllHarnessesAndTestElements(queries);
8376
}
8477

8578
// Implemented as part of the `LocatorFactory` interface.
8679
async harnessLoaderFor(selector: string): Promise<HarnessLoader> {
87-
return this.createEnvironment(await this._assertElementFound(selector));
80+
return this.createEnvironment(await _assertResultFound(this.getAllRawElements(selector),
81+
[_getDescriptionForHarnessLoaderQuery(selector)]));
8882
}
8983

9084
// Implemented as part of the `LocatorFactory` interface.
@@ -100,20 +94,19 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
10094
}
10195

10296
// Implemented as part of the `HarnessLoader` interface.
103-
getHarness<T extends ComponentHarness>(
104-
harnessType: ComponentHarnessConstructor<T> | HarnessPredicate<T>): Promise<T> {
105-
return this.locatorFor(harnessType)();
97+
getHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T> {
98+
return this.locatorFor(query)();
10699
}
107100

108101
// Implemented as part of the `HarnessLoader` interface.
109-
getAllHarnesses<T extends ComponentHarness>(
110-
harnessType: ComponentHarnessConstructor<T> | HarnessPredicate<T>): Promise<T[]> {
111-
return this.locatorForAll(harnessType)();
102+
getAllHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T[]> {
103+
return this.locatorForAll(query)();
112104
}
113105

114106
// Implemented as part of the `HarnessLoader` interface.
115107
async getChildLoader(selector: string): Promise<HarnessLoader> {
116-
return this.createEnvironment(await this._assertElementFound(selector));
108+
return this.createEnvironment(await _assertResultFound(this.getAllRawElements(selector),
109+
[_getDescriptionForHarnessLoaderQuery(selector)]));
117110
}
118111

119112
// Implemented as part of the `HarnessLoader` interface.
@@ -147,43 +140,147 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
147140
*/
148141
protected abstract getAllRawElements(selector: string): Promise<E[]>;
149142

150-
private async _getAllHarnesses<T extends ComponentHarness>(
151-
harnessType: ComponentHarnessConstructor<T> | HarnessPredicate<T>): Promise<T[]> {
152-
const harnessPredicate = harnessType instanceof HarnessPredicate ?
153-
harnessType : new HarnessPredicate(harnessType, {});
154-
const elements = await this.getAllRawElements(harnessPredicate.getSelector());
155-
return harnessPredicate.filter(elements.map(
156-
element => this.createComponentHarness(harnessPredicate.harnessType, element)));
143+
/**
144+
* Matches the given raw elements with the given list of element and harness queries to produce a
145+
* list of matched harnesses and test elements.
146+
*/
147+
private async _getAllHarnessesAndTestElements<T extends (HarnessQuery<any> | string)[]>(
148+
queries: T): Promise<LocatorFnResult<T>[]> {
149+
const {allQueries, harnessQueries, elementQueries, harnessTypes} = _parseQueries(queries);
150+
151+
// Combine all of the queries into one large comma-delimited selector and use it to get all raw
152+
// elements matching any of the individual queries.
153+
const rawElements = await this.getAllRawElements(
154+
[...elementQueries, ...harnessQueries.map(predicate => predicate.getSelector())].join(','));
155+
156+
// If every query is searching for the same harness subclass, we know every result corresponds
157+
// to an instance of that subclass. Likewise, if every query is for a `TestElement`, we know
158+
// every result corresponds to a `TestElement`. Otherwise we need to verify which result was
159+
// found by which selector so it can be matched to the appropriate instance.
160+
const skipSelectorCheck = (elementQueries.length === 0 && harnessTypes.size === 1) ||
161+
harnessQueries.length === 0;
162+
163+
const perElementMatches = await Promise.all(rawElements.map(async rawElement => {
164+
const testElement = this.createTestElement(rawElement);
165+
const allResultsForElement = await Promise.all(
166+
// For each query, get `null` if it doesn't match, or a `TestElement` or
167+
// `ComponentHarness` as appropriate if it does match. This gives us everything that
168+
// matches the current raw element, but it may contain duplicate entries (e.g. multiple
169+
// `TestElement` or multiple `ComponentHarness` of the same type.
170+
allQueries.map(query =>
171+
this._getQueryResultForElement(query, rawElement, testElement, skipSelectorCheck)));
172+
return _removeDuplicateQueryResults(allResultsForElement);
173+
}));
174+
return ([] as any).concat(...perElementMatches);
157175
}
158176

159-
private async _assertElementFound(selector: string): Promise<E> {
160-
const element = (await this.getAllRawElements(selector))[0];
161-
if (!element) {
162-
throw Error(`Expected to find element matching selector: "${selector}", but none was found`);
177+
/**
178+
* Check whether the given query matches the given element, if it does return the matched
179+
* `TestElement` or `ComponentHarness`, if it does not, return null. In cases where the caller
180+
* knows for sure that the query matches the element's selector, `skipSelectorCheck` can be used
181+
* to skip verification and optimize performance.
182+
*/
183+
private async _getQueryResultForElement<T extends ComponentHarness>(
184+
query: string | HarnessPredicate<T>, rawElement: E, testElement: TestElement,
185+
skipSelectorCheck: boolean = false): Promise<T | TestElement | null> {
186+
if (typeof query === 'string') {
187+
return ((skipSelectorCheck || await testElement.matchesSelector(query)) ? testElement : null);
163188
}
164-
return element;
189+
if (skipSelectorCheck || await testElement.matchesSelector(query.getSelector())) {
190+
const harness = this.createComponentHarness(query.harnessType, rawElement);
191+
return (await query.evaluate(harness)) ? harness : null;
192+
}
193+
return null;
165194
}
195+
}
196+
197+
/**
198+
* Parses a list of queries in the format accepted by the `locatorFor*` methods into an easier to
199+
* work with format.
200+
*/
201+
function _parseQueries<T extends (HarnessQuery<any> | string)[]>(queries: T):
202+
ParsedQueries<LocatorFnResult<T> & ComponentHarness> {
203+
const allQueries = [];
204+
const harnessQueries = [];
205+
const elementQueries = [];
206+
const harnessTypes =
207+
new Set<ComponentHarnessConstructor<LocatorFnResult<T> & ComponentHarness>>();
166208

167-
private async _assertHarnessFound<T extends ComponentHarness>(
168-
harnessType: ComponentHarnessConstructor<T> | HarnessPredicate<T>): Promise<T> {
169-
const harness = (await this._getAllHarnesses(harnessType))[0];
170-
if (!harness) {
171-
throw _getErrorForMissingHarness(harnessType);
209+
for (const query of queries) {
210+
if (typeof query === 'string') {
211+
allQueries.push(query);
212+
elementQueries.push(query);
213+
} else {
214+
const predicate = query instanceof HarnessPredicate ? query : new HarnessPredicate(query, {});
215+
allQueries.push(predicate);
216+
harnessQueries.push(predicate);
217+
harnessTypes.add(predicate.harnessType);
172218
}
173-
return harness;
174219
}
220+
221+
return {allQueries, harnessQueries, elementQueries, harnessTypes};
222+
}
223+
224+
/**
225+
* Removes duplicate query results for a particular element. (e.g. multiple `TestElement`
226+
* instances or multiple instances of the same `ComponentHarness` class.
227+
*/
228+
async function _removeDuplicateQueryResults<T extends (ComponentHarness | TestElement | null)[]>(
229+
results: T): Promise<T> {
230+
let testElementMatched = false;
231+
let matchedHarnessTypes = new Set();
232+
const dedupedMatches = [];
233+
for (const result of results) {
234+
if (!result) {
235+
continue;
236+
}
237+
if (result instanceof ComponentHarness) {
238+
if (!matchedHarnessTypes.has(result.constructor)) {
239+
matchedHarnessTypes.add(result.constructor);
240+
dedupedMatches.push(result);
241+
}
242+
} else if (!testElementMatched) {
243+
testElementMatched = true;
244+
dedupedMatches.push(result);
245+
}
246+
}
247+
return dedupedMatches as T;
175248
}
176249

177-
function _getErrorForMissingHarness<T extends ComponentHarness>(
178-
harnessType: ComponentHarnessConstructor<T> | HarnessPredicate<T>): Error {
250+
/** Verifies that there is at least one result in an array. */
251+
async function _assertResultFound<T>(results: Promise<T[]>, queryDescriptions: string[]):
252+
Promise<T> {
253+
const result = (await results)[0];
254+
if (result == undefined) {
255+
throw Error(`Failed to find element matching one of the following queries:\n` +
256+
queryDescriptions.map(desc => `(${desc})`).join(',\n'));
257+
}
258+
return result;
259+
}
260+
261+
/** Gets a list of description strings from a list of queries. */
262+
function _getDescriptionForLocatorForQueries(queries: (string | HarnessQuery<any>)[]) {
263+
return queries.map(query => typeof query === 'string' ?
264+
_getDescriptionForTestElementQuery(query) : _getDescriptionForComponentHarnessQuery(query));
265+
}
266+
267+
/** Gets a description string for a `ComponentHarness` query. */
268+
function _getDescriptionForComponentHarnessQuery(query: HarnessQuery<any>) {
179269
const harnessPredicate =
180-
harnessType instanceof HarnessPredicate ? harnessType : new HarnessPredicate(harnessType, {});
270+
query instanceof HarnessPredicate ? query : new HarnessPredicate(query, {});
181271
const {name, hostSelector} = harnessPredicate.harnessType;
182-
let restrictions = harnessPredicate.getDescription();
183-
let message = `Expected to find element for ${name} matching selector: "${hostSelector}"`;
184-
if (restrictions) {
185-
message += ` (with restrictions: ${restrictions})`;
186-
}
187-
message += ', but none was found';
188-
return Error(message);
272+
const description = `${name} with host element matching selector: "${hostSelector}"`;
273+
const constraints = harnessPredicate.getDescription();
274+
return description + (constraints ?
275+
` satisfying the constraints: ${harnessPredicate.getDescription()}` : '');
276+
}
277+
278+
/** Gets a description string for a `TestElement` query. */
279+
function _getDescriptionForTestElementQuery(selector: string) {
280+
return `TestElement for element matching selector: "${selector}"`;
281+
}
282+
283+
/** Gets a description string for a `HarnessLoader` query. */
284+
function _getDescriptionForHarnessLoaderQuery(selector: string) {
285+
return `HarnessLoader for element matching selector: "${selector}"`;
189286
}

src/cdk/testing/private/expect-async-error.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@
1010
* Expects the asynchronous function to throw an error that matches
1111
* the specified expectation.
1212
*/
13-
export async function expectAsyncError(fn: () => Promise<any>, expectation: RegExp) {
13+
export async function expectAsyncError(fn: () => Promise<any>, expectation: RegExp | string) {
1414
let error: string|null = null;
1515
try {
1616
await fn();
1717
} catch (e) {
1818
error = e.toString();
1919
}
2020
expect(error).not.toBe(null);
21-
expect(error!).toMatch(expectation, 'Expected error to be thrown.');
21+
if (expectation instanceof RegExp) {
22+
expect(error!).toMatch(expectation, 'Expected error to be thrown.');
23+
} else {
24+
expect(error!).toBe(expectation, 'Expected error to be throw.');
25+
}
2226
}

src/cdk/testing/tests/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ ng_test_library(
3737
":test_components",
3838
":test_harnesses",
3939
"//src/cdk/testing",
40+
"//src/cdk/testing/private",
4041
"//src/cdk/testing/testbed",
4142
],
4243
)
@@ -47,6 +48,7 @@ ng_e2e_test_library(
4748
deps = [
4849
":test_harnesses",
4950
"//src/cdk/testing",
51+
"//src/cdk/testing/private",
5052
"//src/cdk/testing/protractor",
5153
],
5254
)

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {ComponentHarness} from '../../component-harness';
1010
import {TestElement, TestKey} from '../../test-element';
11-
import {SubComponentHarness} from './sub-component-harness';
11+
import {SubComponentHarness, SubComponentSpecialHarness} from './sub-component-harness';
1212

1313
export class WrongComponentHarness extends ComponentHarness {
1414
static readonly hostSelector = 'wrong-selector';
@@ -72,6 +72,15 @@ export class MainComponentHarness extends ComponentHarness {
7272
readonly directAncestorSelectorSubcomponent =
7373
this.locatorForAll(SubComponentHarness.with({ancestor: '.other >'}));
7474

75+
readonly subcomponentHarnessesAndElements =
76+
this.locatorForAll('#counter', SubComponentHarness);
77+
readonly subcomponentHarnessAndElementsRedundant =
78+
this.locatorForAll(
79+
SubComponentHarness.with({title: /test/}), 'test-sub', SubComponentHarness, 'test-sub');
80+
readonly subcomponentAndSpecialHarnesses =
81+
this.locatorForAll(SubComponentHarness, SubComponentSpecialHarness);
82+
readonly missingElementsAndHarnesses =
83+
this.locatorFor('.not-found', SubComponentHarness.with({title: /not found/}));
7584

7685
private _testTools = this.locatorFor(SubComponentHarness);
7786

0 commit comments

Comments
 (0)