From ce763949bdc69d96a2dcac1dedafa4c72479dc32 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Fri, 8 Nov 2019 17:30:37 -0800 Subject: [PATCH 1/6] feat(cdk/testing): support querying for multiple TestHarness / ComponentHarness at once in locatorFor --- src/cdk/testing/component-harness.ts | 343 +++++++++++------- src/cdk/testing/harness-environment.ts | 269 ++++++++++---- src/cdk/testing/private/expect-async-error.ts | 8 +- src/cdk/testing/tests/BUILD.bazel | 2 + .../tests/harnesses/main-component-harness.ts | 11 +- .../tests/harnesses/sub-component-harness.ts | 11 +- src/cdk/testing/tests/protractor.e2e.spec.ts | 124 ++++++- .../testing/tests/test-main-component.html | 2 +- src/cdk/testing/tests/testbed.spec.ts | 124 ++++++- .../form-field/testing/form-field-harness.ts | 8 +- 10 files changed, 651 insertions(+), 251 deletions(-) diff --git a/src/cdk/testing/component-harness.ts b/src/cdk/testing/component-harness.ts index d8018f4be62a..76a2260187d4 100644 --- a/src/cdk/testing/component-harness.ts +++ b/src/cdk/testing/component-harness.ts @@ -17,6 +17,43 @@ export type AsyncPredicate = (item: T) => Promise; /** An async function that takes an item and an option value and returns a boolean promise. */ export type AsyncOptionPredicate = (item: T, option: O) => Promise; +/** + * A query for a `ComponentHarness`, which is expressed as either a `ComponentHarnessConstructor` or + * a `HarnessPredicate`. + */ +export type HarnessQuery = + ComponentHarnessConstructor | HarnessPredicate; + +/** + * The type returned by the functions that the `locatorFor*` methods create. + * Maps the input type array and creates a type union from the mapped array: + * - `ComponentHarnessConstructor<T>` maps to `T` + * - `HarnessPredicate<T>` maps to `T` + * - `string` maps to `TestElement` + * + * e.g. + * The type: + * `LocatorFnResult<[ + * ComponentHarnessConstructor<MyHarness>, + * HarnessPredicate<MyOtherHarness>, + * string + * ]>` + * is equivalent to: + * `MyHarness | MyOtherHarness | TestElement`. + */ +export type LocatorFnResult | string)[]> = { + [I in keyof T]: + // Map `ComponentHarnessConstructor` to `C`. + T[I] extends new (...args: any[]) => infer C ? C : + // Map `HarnessPredicate` to `C`. + T[I] extends { harnessType: new (...args: any[]) => infer C } ? C : + // Map `string` to `TestElement`. + T[I] extends string ? TestElement : + // Map everything else to `never` (should not happen due to the type constraint on `T`). + never; +}[number]; + + /** * Interface used to load ComponentHarness objects. This interface is used by test authors to * instantiate `ComponentHarness`es. @@ -46,21 +83,19 @@ export interface HarnessLoader { * `HarnessLoader`'s root element, and returns a `ComponentHarness` for that instance. If multiple * matching components are found, a harness for the first one is returned. If no matching * component is found, an error is thrown. - * @param harnessType The type of harness to create + * @param query A query for a harness to create * @return An instance of the given harness type * @throws If a matching component instance can't be found. */ - getHarness( - harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise; + getHarness(query: HarnessQuery): Promise; /** * Searches for all instances of the component corresponding to the given harness type under the * `HarnessLoader`'s root element, and returns a list `ComponentHarness` for each instance. - * @param harnessType The type of harness to create + * @param query A query for a harness to create * @return A list instances of the given harness type. */ - getAllHarnesses( - harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise; + getAllHarnesses(query: HarnessQuery): Promise; } /** @@ -76,72 +111,88 @@ export interface LocatorFactory { rootElement: TestElement; /** - * Creates an asynchronous locator function that can be used to search for elements with the given - * selector under the root element of this `LocatorFactory`. When the resulting locator function - * is invoked, if multiple matching elements are found, the first element is returned. If no - * elements are found, an error is thrown. - * @param selector The selector for the element that the locator function should search for. - * @return An asynchronous locator function that searches for elements with the given selector, - * and either finds one or throws an error - */ - locatorFor(selector: string): AsyncFactoryFn; - - /** - * Creates an asynchronous locator function that can be used to find a `ComponentHarness` for a - * component matching the given harness type under the root element of this `LocatorFactory`. - * When the resulting locator function is invoked, if multiple matching components are found, a - * harness for the first one is returned. If no components are found, an error is thrown. - * @param harnessType The type of harness to search for. - * @return An asynchronous locator function that searches components matching the given harness - * type, and either returns a `ComponentHarness` for the component, or throws an error. - */ - locatorFor( - harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - - /** - * Creates an asynchronous locator function that can be used to search for elements with the given - * selector under the root element of this `LocatorFactory`. When the resulting locator function - * is invoked, if multiple matching elements are found, the first element is returned. If no - * elements are found, null is returned. - * @param selector The selector for the element that the locator function should search for. - * @return An asynchronous locator function that searches for elements with the given selector, - * and either finds one or returns null. - */ - locatorForOptional(selector: string): AsyncFactoryFn; - - /** - * Creates an asynchronous locator function that can be used to find a `ComponentHarness` for a - * component matching the given harness type under the root element of this `LocatorFactory`. - * When the resulting locator function is invoked, if multiple matching components are found, a - * harness for the first one is returned. If no components are found, null is returned. - * @param harnessType The type of harness to search for. - * @return An asynchronous locator function that searches components matching the given harness - * type, and either returns a `ComponentHarness` for the component, or null if none is found. + * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance + * or element under the root element of this `LocatorFactory`. + * @param {...*} queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the selector specified by the string. + * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the + * given class. + * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given + * predicate. + * @return An asynchronous locator function that searches for and returns a `Promise` for the + * first element or harness matching the given search criteria. Matches are ordered first by + * order in the DOM, and second by order in the queries list. If no matches are found, the + * `Promise` rejects. + * + * e.g. Given the following DOM: `
`, and assuming + * `DivHarness.hostSelector === 'div'`: + * - `await lf.locatorFor(DivHarness, 'div')()` gets a `DivHarness` instance for `#d1` + * - `await lf.locatorFor('div', DivHarness)()` gets a `TestElement` instance for `#d1` + * - `await lf.locatorFor('span')()` throws because the `Promise` rejects. */ - locatorForOptional( - harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; + locatorFor | string)[]>(...queries: T): + AsyncFactoryFn>; /** - * Creates an asynchronous locator function that can be used to search for a list of elements with - * the given selector under the root element of this `LocatorFactory`. When the resulting locator - * function is invoked, a list of matching elements is returned. - * @param selector The selector for the element that the locator function should search for. - * @return An asynchronous locator function that searches for elements with the given selector, - * and either finds one or throws an error + * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance + * or element under the root element of this `LocatorFactory`. + * @param {...*} queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the selector specified by the string. + * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the + * given class. + * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given + * predicate. + * @return An asynchronous locator function that searches for and returns a `Promise` for the + * first element or harness matching the given search criteria. Matches are ordered first by + * order in the DOM, and second by order in the queries list. If no matches are found, the + * `Promise` is resolved with `null`. + * + * e.g. Given the following DOM: `
`, and assuming + * `DivHarness.hostSelector === 'div'`: + * - `await lf.locatorForOptional(DivHarness, 'div')()` gets a `DivHarness` instance for `#d1` + * - `await lf.locatorForOptional('div', DivHarness)()` gets a `TestElement` instance for `#d1` + * - `await lf.locatorForOptional('span')()` gets `null`. */ - locatorForAll(selector: string): AsyncFactoryFn; + locatorForOptional | string)[]>(...queries: T): + AsyncFactoryFn | null>; /** - * Creates an asynchronous locator function that can be used to find a list of - * `ComponentHarness`es for all components matching the given harness type under the root element - * of this `LocatorFactory`. When the resulting locator function is invoked, a list of - * `ComponentHarness`es for the matching components is returned. - * @param harnessType The type of harness to search for. - * @return An asynchronous locator function that searches components matching the given harness - * type, and returns a list of `ComponentHarness`es. + * Creates an asynchronous locator function that can be used to find `ComponentHarness` instances + * or elements under the root element of this `LocatorFactory`. + * @param {...*} queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the selector specified by the string. + * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the + * given class. + * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given + * predicate. + * @return An asynchronous locator function that searches for and returns a `Promise` for all + * elements and harnesses matching the given search criteria. Matches are ordered first by + * order in the DOM, and second by order in the queries list. If an element matches more than + * one `ComponentHarness` class, the locator gets an instance of each for the same element. If + * an element matches multiple `string` selectors, only one `TestElement` instance is returned + * for that element. + * + * e.g. Given the following DOM: `
`, and assuming + * `DivHarness.hostSelector === 'div'` and `IdIsD1Harness.hostSelector === '#d1'`: + * - `await lf.locatorForAll(DivHarness, 'div')()` gets `[ + * DivHarness, // for #d1 + * TestElement, // for #d1 + * DivHarness, // for #d2 + * TestElement // for #d2 + * ]` + * - `await lf.locatorForAll('div', '#d1')()` gets `[ + * TestElement, // for #d1 + * TestElement // for #d2 + * ]` + * - `await lf.locatorForAll(DivHarness, IdIsD1Harness)()` gets `[ + * DivHarness, // for #d1 + * IdIsD1Harness, // for #d1 + * DivHarness // for #d2 + * ]` + * - `await lf.locatorForAll('span')()` gets `[]`. */ - locatorForAll( - harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; + locatorForAll | string)[]>(...queries: T): + AsyncFactoryFn[]>; /** * Gets a `HarnessLoader` instance for an element under the root of this `LocatorFactory`. @@ -203,83 +254,93 @@ export abstract class ComponentHarness { } /** - * Creates an asynchronous locator function that can be used to search for elements with the given - * selector under the host element of this `ComponentHarness`. When the resulting locator function - * is invoked, if multiple matching elements are found, the first element is returned. If no - * elements are found, an error is thrown. - * @param selector The selector for the element that the locator function should search for. - * @return An asynchronous locator function that searches for elements with the given selector, - * and either finds one or throws an error - */ - protected locatorFor(selector: string): AsyncFactoryFn; - - /** - * Creates an asynchronous locator function that can be used to find a `ComponentHarness` for a - * component matching the given harness type under the host element of this `ComponentHarness`. - * When the resulting locator function is invoked, if multiple matching components are found, a - * harness for the first one is returned. If no components are found, an error is thrown. - * @param harnessType The type of harness to search for. - * @return An asynchronous locator function that searches components matching the given harness - * type, and either returns a `ComponentHarness` for the component, or throws an error. + * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance + * or element under the host element of this `ComponentHarness`. + * @param {...*} queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the selector specified by the string. + * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the + * given class. + * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given + * predicate. + * @return An asynchronous locator function that searches for and returns a `Promise` for the + * first element or harness matching the given search criteria. Matches are ordered first by + * order in the DOM, and second by order in the queries list. If no matches are found, the + * `Promise` rejects. + * + * e.g. Given the following DOM: `
`, and assuming + * `DivHarness.hostSelector === 'div'`: + * - `await ch.locatorFor(DivHarness, 'div')()` gets a `DivHarness` instance for `#d1` + * - `await ch.locatorFor('div', DivHarness)()` gets a `TestElement` instance for `#d1` + * - `await ch.locatorFor('span')()` throws because the `Promise` rejects. */ - protected locatorFor( - harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - - protected locatorFor(arg: any) { - return this.locatorFactory.locatorFor(arg); + protected locatorFor | string)[]>(...queries: T): + AsyncFactoryFn> { + return this.locatorFactory.locatorFor(...queries); } /** - * Creates an asynchronous locator function that can be used to search for elements with the given - * selector under the host element of this `ComponentHarness`. When the resulting locator function - * is invoked, if multiple matching elements are found, the first element is returned. If no - * elements are found, null is returned. - * @param selector The selector for the element that the locator function should search for. - * @return An asynchronous locator function that searches for elements with the given selector, - * and either finds one or returns null. - */ - protected locatorForOptional(selector: string): AsyncFactoryFn; - - /** - * Creates an asynchronous locator function that can be used to find a `ComponentHarness` for a - * component matching the given harness type under the host element of this `ComponentHarness`. - * When the resulting locator function is invoked, if multiple matching components are found, a - * harness for the first one is returned. If no components are found, null is returned. - * @param harnessType The type of harness to search for. - * @return An asynchronous locator function that searches components matching the given harness - * type, and either returns a `ComponentHarness` for the component, or null if none is found. + * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance + * or element under the host element of this `ComponentHarness`. + * @param {...*} queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the selector specified by the string. + * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the + * given class. + * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given + * predicate. + * @return An asynchronous locator function that searches for and returns a `Promise` for the + * first element or harness matching the given search criteria. Matches are ordered first by + * order in the DOM, and second by order in the queries list. If no matches are found, the + * `Promise` is resolved with `null`. + * + * e.g. Given the following DOM: `
`, and assuming + * `DivHarness.hostSelector === 'div'`: + * - `await ch.locatorForOptional(DivHarness, 'div')()` gets a `DivHarness` instance for `#d1` + * - `await ch.locatorForOptional('div', DivHarness)()` gets a `TestElement` instance for `#d1` + * - `await ch.locatorForOptional('span')()` gets `null`. */ - protected locatorForOptional( - harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - - protected locatorForOptional(arg: any) { - return this.locatorFactory.locatorForOptional(arg); + protected locatorForOptional | string)[]>(...queries: T): + AsyncFactoryFn | null> { + return this.locatorFactory.locatorForOptional(...queries); } /** - * Creates an asynchronous locator function that can be used to search for a list of elements with - * the given selector under the host element of this `ComponentHarness`. When the resulting - * locator function is invoked, a list of matching elements is returned. - * @param selector The selector for the element that the locator function should search for. - * @return An asynchronous locator function that searches for elements with the given selector, - * and either finds one or throws an error - */ - protected locatorForAll(selector: string): AsyncFactoryFn; - - /** - * Creates an asynchronous locator function that can be used to find a list of - * `ComponentHarness`es for all components matching the given harness type under the host element - * of this `ComponentHarness`. When the resulting locator function is invoked, a list of - * `ComponentHarness`es for the matching components is returned. - * @param harnessType The type of harness to search for. - * @return An asynchronous locator function that searches components matching the given harness - * type, and returns a list of `ComponentHarness`es. + * Creates an asynchronous locator function that can be used to find `ComponentHarness` instances + * or elements under the host element of this `ComponentHarness`. + * @param {...*} queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the selector specified by the string. + * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the + * given class. + * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given + * predicate. + * @return An asynchronous locator function that searches for and returns a `Promise` for all + * elements and harnesses matching the given search criteria. Matches are ordered first by + * order in the DOM, and second by order in the queries list. If an element matches more than + * one `ComponentHarness` class, the locator gets an instance of each for the same element. If + * an element matches multiple `string` selectors, only one `TestElement` instance is returned + * for that element. + * + * e.g. Given the following DOM: `
`, and assuming + * `DivHarness.hostSelector === 'div'` and `IdIsD1Harness.hostSelector === '#d1'`: + * - `await ch.locatorForAll(DivHarness, 'div')()` gets `[ + * DivHarness, // for #d1 + * TestElement, // for #d1 + * DivHarness, // for #d2 + * TestElement // for #d2 + * ]` + * - `await ch.locatorForAll('div', '#d1')()` gets `[ + * TestElement, // for #d1 + * TestElement // for #d2 + * ]` + * - `await ch.locatorForAll(DivHarness, IdIsD1Harness)()` gets `[ + * DivHarness, // for #d1 + * IdIsD1Harness, // for #d1 + * DivHarness // for #d2 + * ]` + * - `await ch.locatorForAll('span')()` gets `[]`. */ - protected locatorForAll( - harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - - protected locatorForAll(arg: any) { - return this.locatorFactory.locatorForAll(arg); + protected locatorForAll | string)[]>(...queries: T): + AsyncFactoryFn[]> { + return this.locatorFactory.locatorForAll(...queries); } /** @@ -366,10 +427,8 @@ export class HarnessPredicate { * @return this (for method chaining). */ addOption(name: string, option: O | undefined, predicate: AsyncOptionPredicate) { - // Add quotes around strings to differentiate them from other values - const value = typeof option === 'string' ? `"${option}"` : `${option}`; if (option !== undefined) { - this.add(`${name} = ${value}`, item => predicate(item, option)); + this.add(`${name} = ${_valueAsString(option)}`, item => predicate(item, option)); } return this; } @@ -421,3 +480,15 @@ export class HarnessPredicate { } } } + +/** Represent a value as a string for the purpose of logging. */ +function _valueAsString(value: unknown) { + if (value === undefined) { + return 'undefined'; + } + // `JSON.stringify` doesn't handle RegExp properly, so we need a custom replacer. + return JSON.stringify(value, (_, v) => + v instanceof RegExp ? `/${v.toString()}/` : + typeof v === 'string' ? v.replace('/\//g', '\\/') : v + ).replace(/"\/\//g, '\\/').replace(/\/\/"/g, '\\/').replace(/\\\//g, '/'); +} diff --git a/src/cdk/testing/harness-environment.ts b/src/cdk/testing/harness-environment.ts index 095f9623026f..7de6f5707276 100644 --- a/src/cdk/testing/harness-environment.ts +++ b/src/cdk/testing/harness-environment.ts @@ -12,10 +12,20 @@ import { ComponentHarnessConstructor, HarnessLoader, HarnessPredicate, - LocatorFactory + HarnessQuery, + LocatorFactory, + LocatorFnResult, } from './component-harness'; import {TestElement} from './test-element'; +/** Parsed form of the queries passed to the `locatorFor*` methods. */ +type ParsedQueries = { + allQueries: (string | HarnessPredicate)[], + harnessQueries: HarnessPredicate[], + elementQueries: string[], + harnessTypes: Set>, +}; + /** * Base harness environment class that can be extended to allow `ComponentHarness`es to be used in * different test environments (e.g. testbed, protractor, etc.). This class implements the @@ -36,55 +46,29 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac } // Implemented as part of the `LocatorFactory` interface. - locatorFor(selector: string): AsyncFactoryFn; - locatorFor( - harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - locatorFor( - arg: string | ComponentHarnessConstructor | HarnessPredicate) { - return async () => { - if (typeof arg === 'string') { - return this.createTestElement(await this._assertElementFound(arg)); - } else { - return this._assertHarnessFound(arg); - } - }; + locatorFor | string)[]>(...queries: T): + AsyncFactoryFn> { + return () => _assertResultFound( + this._getAllHarnessesAndTestElements(queries), + _getDescriptionForLocatorForQueries(queries)); } // Implemented as part of the `LocatorFactory` interface. - locatorForOptional(selector: string): AsyncFactoryFn; - locatorForOptional( - harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - locatorForOptional( - arg: string | ComponentHarnessConstructor | HarnessPredicate) { - return async () => { - if (typeof arg === 'string') { - const element = (await this.getAllRawElements(arg))[0]; - return element ? this.createTestElement(element) : null; - } else { - const candidates = await this._getAllHarnesses(arg); - return candidates[0] || null; - } - }; + locatorForOptional | string)[]>(...queries: T): + AsyncFactoryFn | null> { + return async () => (await this._getAllHarnessesAndTestElements(queries))[0] || null; } // Implemented as part of the `LocatorFactory` interface. - locatorForAll(selector: string): AsyncFactoryFn; - locatorForAll( - harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - locatorForAll( - arg: string | ComponentHarnessConstructor | HarnessPredicate) { - return async () => { - if (typeof arg === 'string') { - return (await this.getAllRawElements(arg)).map(e => this.createTestElement(e)); - } else { - return this._getAllHarnesses(arg); - } - }; + locatorForAll | string)[]>(...queries: T): + AsyncFactoryFn[]> { + return () => this._getAllHarnessesAndTestElements(queries); } // Implemented as part of the `LocatorFactory` interface. async harnessLoaderFor(selector: string): Promise { - return this.createEnvironment(await this._assertElementFound(selector)); + return this.createEnvironment(await _assertResultFound(this.getAllRawElements(selector), + [_getDescriptionForHarnessLoaderQuery(selector)])); } // Implemented as part of the `LocatorFactory` interface. @@ -100,20 +84,19 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac } // Implemented as part of the `HarnessLoader` interface. - getHarness( - harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise { - return this.locatorFor(harnessType)(); + getHarness(query: HarnessQuery): Promise { + return this.locatorFor(query)(); } // Implemented as part of the `HarnessLoader` interface. - getAllHarnesses( - harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise { - return this.locatorForAll(harnessType)(); + getAllHarnesses(query: HarnessQuery): Promise { + return this.locatorForAll(query)(); } // Implemented as part of the `HarnessLoader` interface. async getChildLoader(selector: string): Promise { - return this.createEnvironment(await this._assertElementFound(selector)); + return this.createEnvironment(await _assertResultFound(this.getAllRawElements(selector), + [_getDescriptionForHarnessLoaderQuery(selector)])); } // Implemented as part of the `HarnessLoader` interface. @@ -147,43 +130,179 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac */ protected abstract getAllRawElements(selector: string): Promise; - private async _getAllHarnesses( - harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise { - const harnessPredicate = harnessType instanceof HarnessPredicate ? - harnessType : new HarnessPredicate(harnessType, {}); - const elements = await this.getAllRawElements(harnessPredicate.getSelector()); - return harnessPredicate.filter(elements.map( - element => this.createComponentHarness(harnessPredicate.harnessType, element))); + /** Gets a list of matching harnesses and test elements for a given query list. */ + private async _getAllHarnessesAndTestElements | string)[]>( + queries: T): Promise[]> { + const {allQueries, harnessQueries, elementQueries, harnessTypes} = _parseQueries(queries); + // Combine all of the queries into one large comma-delimited selector and use it to get all raw + // elements matching any of the individual queries. + const rawElements = await this.getAllRawElements( + [...elementQueries, ...harnessQueries.map(pred => pred.getSelector())].join(',')); + // If there are only element queries, we can just return a `TestElement` for each raw element + // that was matched. + if (harnessQueries.length === 0) { + return this._matchRawElementsToTestElements(rawElements) as LocatorFnResult[]; + } + // If the queries contain only searches for a single harness type, we can just create instances + // of that class for all raw elements and filter out the ones that don't match any query. + if (elementQueries.length === 0 && harnessTypes.size === 1) { + return this._matchRawElementsToHarnessType( + rawElements, harnessQueries[0].harnessType, harnessQueries); + } + // Otherwise the queries are come combination of different ComponentHarness classes and string + // selectors. In this case we need to do extra matching ahead of time to determine which + // selectors were responsible for finding which raw elements. + return this._matchRawElementsToHarnessesAndTestElements(rawElements, allQueries); + } + + /** Matches the given raw elements with test elements. */ + private _matchRawElementsToTestElements(rawElements: E[]): TestElement[] { + return rawElements.map(rawElement => this.createTestElement(rawElement)); + } + + /** + * Matches the given raw elements with a list of harness predicates that all produce the same type + * of harness to produce a list of matched harnesses. + */ + private async _matchRawElementsToHarnessType( + rawElements: E[], harnessType: ComponentHarnessConstructor, + harnessPredicates: HarnessPredicate[]): Promise { + return ( + await Promise.all(rawElements.map(rawElement => { + const harness = this.createComponentHarness(harnessType, rawElement); + // Get a list of boolean results by comparing the harness against each predicate, then + // check if the harness matched any of them. + return Promise.all(harnessPredicates.map(pred => pred.evaluate(harness))) + .then(matches => matches.some(isMatch => isMatch) ? harness : null); + })) + ).filter((harness): harness is T => !!harness); + } + + /** + * Matches the given raw elements with the given list of element and harness queries to produce a + * list of matched harnesses and test elements. + */ + private async _matchRawElementsToHarnessesAndTestElements)[]>(rawElements: E[], allQueries: T): + Promise<(LocatorFnResult)[]> { + return Promise.all(rawElements.map(rawElement => { + const testElement = this.createTestElement(rawElement); + return Promise.all( + // For each query, get `null` if it doesn't match, or a `TestElement` or + // `ComponentHarness` as appropriate if it does match. This gives us everything that + // matches the current raw element, but it may contain duplicate entries (e.g. multiple + // `TestElement` or multiple `ComponentHarness` of the same type. + allQueries.map(query => this._getQueryResultForElement(query, rawElement, testElement)) + ).then(allResultsForElement => _removeDuplicateQueryResults(allResultsForElement)); + })).then(perElementMatches => ([] as any).concat(...perElementMatches)); } - private async _assertElementFound(selector: string): Promise { - const element = (await this.getAllRawElements(selector))[0]; - if (!element) { - throw Error(`Expected to find element matching selector: "${selector}", but none was found`); + /** + * Check whether the given query matches the given element, if it does return the matched + * `TestElement` or `ComponentHarness`, if it does not, return null. + */ + private async _getQueryResultForElement( + query: string | HarnessPredicate, rawElement: E, testElement: TestElement): + Promise { + if (typeof query === 'string') { + return testElement.matchesSelector(query) + .then(selectorMatches => selectorMatches ? testElement : null); } - return element; + return testElement.matchesSelector(query.getSelector()).then(selectorMatches => { + if (selectorMatches) { + const harness = this.createComponentHarness(query.harnessType, rawElement); + return query.evaluate(harness).then(predMatches => predMatches ? harness : null); + } + return null; + }); } +} - private async _assertHarnessFound( - harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise { - const harness = (await this._getAllHarnesses(harnessType))[0]; - if (!harness) { - throw _getErrorForMissingHarness(harnessType); +/** + * Parses a list of queries in the format accepted by the `locatorFor*` methods into an easier to + * work with format. + */ +function _parseQueries | string)[]>(queries: T): + ParsedQueries & ComponentHarness> { + const allQueries = []; + const harnessQueries = []; + const elementQueries = []; + const harnessTypes = + new Set & ComponentHarness>>(); + + for (const query of queries) { + if (typeof query === 'string') { + allQueries.push(query); + elementQueries.push(query); + } else { + const pred = query instanceof HarnessPredicate ? query : new HarnessPredicate(query, {}); + allQueries.push(pred); + harnessQueries.push(pred); + harnessTypes.add(pred.harnessType); + } + } + + return {allQueries, harnessQueries, elementQueries, harnessTypes}; +} + +/** + * Removes duplicate query results for a particular element. (e.g. multiple `TestElement` + * instances or multiple instances of the same `ComponentHarness` class. + */ +async function _removeDuplicateQueryResults( + results: T): Promise { + let testElementMatched = false; + let matchedHarnessTypes = new Set(); + const dedupedMatches = []; + for (const result of results) { + if (!result) { + continue; + } + if (result instanceof ComponentHarness) { + if (!matchedHarnessTypes.has(result.constructor)) { + matchedHarnessTypes.add(result.constructor); + dedupedMatches.push(result); + } + } else { + if (!testElementMatched) { + testElementMatched = true; + dedupedMatches.push(result); + } } - return harness; } + return dedupedMatches as T; } -function _getErrorForMissingHarness( - harnessType: ComponentHarnessConstructor | HarnessPredicate): Error { +/** Verifies that there is at least one result in an array. */ +async function _assertResultFound(results: Promise, queryDescriptions: string[]): + Promise { + const result = (await results)[0]; + if (result == undefined) { + throw Error(`Failed to find element matching one of the following queries:\n` + + queryDescriptions.map(desc => `(${desc})`).join(',\n')); + } + return result; +} + +function _getDescriptionForLocatorForQueries(queries: (string | HarnessQuery)[]) { + return queries.map(query => typeof query === 'string' ? + _getDescriptionForTestElementQuery(query) : _getDescriptionForComponentHarnessQuery(query)); +} + +function _getDescriptionForComponentHarnessQuery(query: HarnessQuery) { const harnessPredicate = - harnessType instanceof HarnessPredicate ? harnessType : new HarnessPredicate(harnessType, {}); + query instanceof HarnessPredicate ? query : new HarnessPredicate(query, {}); const {name, hostSelector} = harnessPredicate.harnessType; - let restrictions = harnessPredicate.getDescription(); - let message = `Expected to find element for ${name} matching selector: "${hostSelector}"`; - if (restrictions) { - message += ` (with restrictions: ${restrictions})`; - } - message += ', but none was found'; - return Error(message); + const description = `${name} with host element matching selector: "${hostSelector}"`; + const constraints = harnessPredicate.getDescription(); + return description + (constraints ? + ` satisfying the constraints: ${harnessPredicate.getDescription()}` : ''); +} + +function _getDescriptionForTestElementQuery(selector: string) { + return `TestElement for element matching selector: "${selector}"`; +} + +function _getDescriptionForHarnessLoaderQuery(selector: string) { + return `HarnessLoader for element matching selector: "${selector}"`; } diff --git a/src/cdk/testing/private/expect-async-error.ts b/src/cdk/testing/private/expect-async-error.ts index 1a6dde41e608..ac599b5412ed 100644 --- a/src/cdk/testing/private/expect-async-error.ts +++ b/src/cdk/testing/private/expect-async-error.ts @@ -10,7 +10,7 @@ * Expects the asynchronous function to throw an error that matches * the specified expectation. */ -export async function expectAsyncError(fn: () => Promise, expectation: RegExp) { +export async function expectAsyncError(fn: () => Promise, expectation: RegExp | string) { let error: string|null = null; try { await fn(); @@ -18,5 +18,9 @@ export async function expectAsyncError(fn: () => Promise, expectation: RegE error = e.toString(); } expect(error).not.toBe(null); - expect(error!).toMatch(expectation, 'Expected error to be thrown.'); + if (expectation instanceof RegExp) { + expect(error!).toMatch(expectation, 'Expected error to be thrown.'); + } else { + expect(error!).toBe(expectation, 'Expected error to be throw.'); + } } diff --git a/src/cdk/testing/tests/BUILD.bazel b/src/cdk/testing/tests/BUILD.bazel index 6d2f9bed705c..2daa86c1ca40 100644 --- a/src/cdk/testing/tests/BUILD.bazel +++ b/src/cdk/testing/tests/BUILD.bazel @@ -37,6 +37,7 @@ ng_test_library( ":test_components", ":test_harnesses", "//src/cdk/testing", + "//src/cdk/testing/private", "//src/cdk/testing/testbed", ], ) @@ -47,6 +48,7 @@ ng_e2e_test_library( deps = [ ":test_harnesses", "//src/cdk/testing", + "//src/cdk/testing/private", "//src/cdk/testing/protractor", ], ) diff --git a/src/cdk/testing/tests/harnesses/main-component-harness.ts b/src/cdk/testing/tests/harnesses/main-component-harness.ts index 45aaffe63b9a..5ccf385b0c20 100644 --- a/src/cdk/testing/tests/harnesses/main-component-harness.ts +++ b/src/cdk/testing/tests/harnesses/main-component-harness.ts @@ -8,7 +8,7 @@ import {ComponentHarness} from '../../component-harness'; import {TestElement, TestKey} from '../../test-element'; -import {SubComponentHarness} from './sub-component-harness'; +import {SubComponentHarness, SubComponentSpecialHarness} from './sub-component-harness'; export class WrongComponentHarness extends ComponentHarness { static readonly hostSelector = 'wrong-selector'; @@ -72,6 +72,15 @@ export class MainComponentHarness extends ComponentHarness { readonly directAncestorSelectorSubcomponent = this.locatorForAll(SubComponentHarness.with({ancestor: '.other >'})); + readonly subcomponentHarnessesAndElements = + this.locatorForAll('#counter', SubComponentHarness); + readonly subcomponentHarnessAndElementsRedundant = + this.locatorForAll( + SubComponentHarness.with({title: /test/}), 'test-sub', SubComponentHarness, 'test-sub'); + readonly subcomponentAndSpecialHarnesses = + this.locatorForAll(SubComponentHarness, SubComponentSpecialHarness); + readonly missingElementsAndHarnesses = + this.locatorFor('.not-found', SubComponentHarness.with({title: /not found/})); private _testTools = this.locatorFor(SubComponentHarness); diff --git a/src/cdk/testing/tests/harnesses/sub-component-harness.ts b/src/cdk/testing/tests/harnesses/sub-component-harness.ts index 18868723a3ea..e9078abd6f0c 100644 --- a/src/cdk/testing/tests/harnesses/sub-component-harness.ts +++ b/src/cdk/testing/tests/harnesses/sub-component-harness.ts @@ -16,7 +16,7 @@ export interface SubComponentHarnessFilters extends BaseHarnessFilters { /** @dynamic */ export class SubComponentHarness extends ComponentHarness { - static readonly hostSelector = 'test-sub'; + static readonly hostSelector: string = 'test-sub'; static with(options: SubComponentHarnessFilters = {}) { return new HarnessPredicate(SubComponentHarness, options) @@ -31,8 +31,17 @@ export class SubComponentHarness extends ComponentHarness { readonly getItems = this.locatorForAll('li'); readonly globalElement = this.documentRootLocatorFactory().locatorFor('#username'); + async titleText() { + return (await this.title()).text(); + } + async getItem(index: number): Promise { const items = await this.getItems(); return items[index]; } } + +/** @dynamic */ +export class SubComponentSpecialHarness extends SubComponentHarness { + static readonly hostSelector = 'test-sub.test-special'; +} diff --git a/src/cdk/testing/tests/protractor.e2e.spec.ts b/src/cdk/testing/tests/protractor.e2e.spec.ts index 12879ba91689..09fb528150d9 100644 --- a/src/cdk/testing/tests/protractor.e2e.spec.ts +++ b/src/cdk/testing/tests/protractor.e2e.spec.ts @@ -1,8 +1,14 @@ -import {HarnessLoader} from '@angular/cdk/testing'; +import { + ComponentHarness, + ComponentHarnessConstructor, + HarnessLoader, + TestElement +} from '@angular/cdk/testing'; +import {expectAsyncError} from '@angular/cdk/testing/private'; import {ProtractorHarnessEnvironment} from '@angular/cdk/testing/protractor'; import {browser} from 'protractor'; import {MainComponentHarness} from './harnesses/main-component-harness'; -import {SubComponentHarness} from './harnesses/sub-component-harness'; +import {SubComponentHarness, SubComponentSpecialHarness} from './harnesses/sub-component-harness'; describe('ProtractorHarnessEnvironment', () => { beforeEach(async () => { @@ -30,8 +36,9 @@ describe('ProtractorHarnessEnvironment', () => { await loader.getChildLoader('error'); fail('Expected to throw'); } catch (e) { - expect(e.message) - .toBe('Expected to find element matching selector: "error", but none was found'); + expect(e.message).toBe( + 'Failed to find element matching one of the following queries:' + + '\n(HarnessLoader for element matching selector: "error")'); } }); @@ -53,8 +60,8 @@ describe('ProtractorHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element for SubComponentHarness matching selector:' + - ' "test-sub", but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(SubComponentHarness with host element matching selector: "test-sub")'); } }); @@ -82,7 +89,8 @@ describe('ProtractorHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element matching selector: "wrong locator", but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(TestElement for element matching selector: "wrong locator")'); } }); @@ -115,8 +123,8 @@ describe('ProtractorHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element for WrongComponentHarness matching selector:' + - ' "wrong-selector", but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(WrongComponentHarness with host element matching selector: "wrong-selector")'); } }); @@ -283,9 +291,9 @@ describe('ProtractorHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element for SubComponentHarness matching selector: "test-sub"' + - ' (with restrictions: has ancestor matching selector ".not-found"),' + - ' but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(SubComponentHarness with host element matching selector: "test-sub"' + + ' satisfying the constraints: has ancestor matching selector ".not-found")'); } }); @@ -321,6 +329,75 @@ describe('ProtractorHarnessEnvironment', () => { const subcomps = await harness.directAncestorSelectorSubcomponent(); expect(subcomps.length).toBe(2); }); + + it('should get TestElements and ComponentHarnesses', async () => { + const results = await harness.subcomponentHarnessesAndElements(); + expect(results.length).toBe(5); + + // The counter should appear in the DOM before the test-sub elements. + await checkIsElement(results[0], '#counter'); + + // Followed by the SubComponentHarness instances in the correct order. + await checkIsHarness(results[1], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test tools')); + await checkIsHarness(results[2], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test methods')); + await checkIsHarness(results[3], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 1')); + await checkIsHarness(results[4], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 2')); + }); + + it('should get TestElements and ComponentHarnesses with redundant queries', async () => { + const results = await harness.subcomponentHarnessAndElementsRedundant(); + expect(results.length).toBe(8); + + // Each subcomponent should have a TestElement result and a SubComponentHarness result. + // For the first two elements, the harness should come first, as it matches the first query + // to locatorForAll, the HarnessPredicate. + await checkIsHarness(results[0], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test tools')); + await checkIsElement(results[1], '.subcomponents test-sub:nth-child(1)'); + await checkIsHarness(results[2], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test methods')); + await checkIsElement(results[3], '.subcomponents test-sub:nth-child(2)'); + + // For the last two elements, the harness should come second, as they do not match the first + // query, therefore the second query, the TestElement selector is the first to match. + await checkIsElement(results[4], '.other test-sub:nth-child(1)'); + await checkIsHarness(results[5], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 1')); + await checkIsElement(results[6], '.other test-sub:nth-child(2)'); + await checkIsHarness(results[7], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 2')); + }); + + it('should get harnesses of different types matching same element', async () => { + const results = await harness.subcomponentAndSpecialHarnesses(); + expect(results.length).toBe(5); + + // The first element should have a SubComponentHarness and a SubComponentSpecialHarness. + await checkIsHarness(results[0], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test tools')); + await checkIsHarness(results[1], SubComponentSpecialHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test tools')); + + // The rest only have a SubComponentHarness. + await checkIsHarness(results[2], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test methods')); + await checkIsHarness(results[3], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 1')); + await checkIsHarness(results[4], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 2')); + }); + + it('should throw when multiple queries fail to match', async () => { + await expectAsyncError(() => harness.missingElementsAndHarnesses(), + 'Error: Failed to find element matching one of the following queries:' + + '\n(TestElement for element matching selector: ".not-found"),' + + '\n(SubComponentHarness with host element matching selector: "test-sub" satisfying' + + ' the constraints: title = /not found/)'); + }); }); describe('HarnessPredicate', () => { @@ -365,10 +442,27 @@ describe('ProtractorHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element for SubComponentHarness matching selector: "test-sub"' + - ' (with restrictions: title = "List of test tools", item count = 4),' + - ' but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(SubComponentHarness with host element matching selector: "test-sub" satisfying' + + ' the constraints: title = "List of test tools", item count = 4)'); } }); }); }); + +async function checkIsElement(result: ComponentHarness | TestElement, selector?: string) { + expect(result instanceof ComponentHarness).toBe(false); + if (selector) { + expect(await (result as TestElement).matchesSelector(selector)).toBe(true); + } +} + +async function checkIsHarness( + result: ComponentHarness | TestElement, + harnessType: ComponentHarnessConstructor, + finalCheck?: (harness: T) => Promise) { + expect(result.constructor === harnessType).toBe(true); + if (finalCheck) { + await finalCheck(result as T); + } +} diff --git a/src/cdk/testing/tests/test-main-component.html b/src/cdk/testing/tests/test-main-component.html index 94f5241011ac..c57442de2a26 100644 --- a/src/cdk/testing/tests/test-main-component.html +++ b/src/cdk/testing/tests/test-main-component.html @@ -19,7 +19,7 @@

Main Component

- +
diff --git a/src/cdk/testing/tests/testbed.spec.ts b/src/cdk/testing/tests/testbed.spec.ts index 737873cae823..e1025a5ee47c 100644 --- a/src/cdk/testing/tests/testbed.spec.ts +++ b/src/cdk/testing/tests/testbed.spec.ts @@ -1,9 +1,15 @@ -import {HarnessLoader} from '@angular/cdk/testing'; +import { + ComponentHarness, + ComponentHarnessConstructor, + HarnessLoader, + TestElement +} from '@angular/cdk/testing'; +import {expectAsyncError} from '@angular/cdk/testing/private'; import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; 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'; +import {SubComponentHarness, SubComponentSpecialHarness} from './harnesses/sub-component-harness'; import {TestComponentsModule} from './test-components-module'; import {TestMainComponent} from './test-main-component'; @@ -46,8 +52,9 @@ describe('TestbedHarnessEnvironment', () => { await loader.getChildLoader('error'); fail('Expected to throw'); } catch (e) { - expect(e.message) - .toBe('Expected to find element matching selector: "error", but none was found'); + expect(e.message).toBe( + 'Failed to find element matching one of the following queries:' + + '\n(HarnessLoader for element matching selector: "error")'); } }); @@ -69,8 +76,8 @@ describe('TestbedHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element for SubComponentHarness matching selector:' + - ' "test-sub", but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(SubComponentHarness with host element matching selector: "test-sub")'); } }); @@ -109,7 +116,8 @@ describe('TestbedHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element matching selector: "wrong locator", but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(TestElement for element matching selector: "wrong locator")'); } }); @@ -142,8 +150,8 @@ describe('TestbedHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element for WrongComponentHarness matching selector:' + - ' "wrong-selector", but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(WrongComponentHarness with host element matching selector: "wrong-selector")'); } }); @@ -214,9 +222,9 @@ describe('TestbedHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element for SubComponentHarness matching selector: "test-sub"' + - ' (with restrictions: has ancestor matching selector ".not-found"),' + - ' but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(SubComponentHarness with host element matching selector: "test-sub"' + + ' satisfying the constraints: has ancestor matching selector ".not-found")'); } }); @@ -364,6 +372,75 @@ describe('TestbedHarnessEnvironment', () => { expect(await button.matchesSelector('button:not(.fake-class)')).toBe(true); expect(await button.matchesSelector('button:disabled')).toBe(false); }); + + it('should get TestElements and ComponentHarnesses', async () => { + const results = await harness.subcomponentHarnessesAndElements(); + expect(results.length).toBe(5); + + // The counter should appear in the DOM before the test-sub elements. + await checkIsElement(results[0], '#counter'); + + // Followed by the SubComponentHarness instances in the correct order. + await checkIsHarness(results[1], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test tools')); + await checkIsHarness(results[2], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test methods')); + await checkIsHarness(results[3], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 1')); + await checkIsHarness(results[4], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 2')); + }); + + it('should get TestElements and ComponentHarnesses with redundant queries', async () => { + const results = await harness.subcomponentHarnessAndElementsRedundant(); + expect(results.length).toBe(8); + + // Each subcomponent should have a TestElement result and a SubComponentHarness result. + // For the first two elements, the harness should come first, as it matches the first query + // to locatorForAll, the HarnessPredicate. + await checkIsHarness(results[0], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test tools')); + await checkIsElement(results[1], '.subcomponents test-sub:nth-child(1)'); + await checkIsHarness(results[2], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test methods')); + await checkIsElement(results[3], '.subcomponents test-sub:nth-child(2)'); + + // For the last two elements, the harness should come second, as they do not match the first + // query, therefore the second query, the TestElement selector is the first to match. + await checkIsElement(results[4], '.other test-sub:nth-child(1)'); + await checkIsHarness(results[5], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 1')); + await checkIsElement(results[6], '.other test-sub:nth-child(2)'); + await checkIsHarness(results[7], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 2')); + }); + + it('should get harnesses of different types matching same element', async () => { + const results = await harness.subcomponentAndSpecialHarnesses(); + expect(results.length).toBe(5); + + // The first element should have a SubComponentHarness and a SubComponentSpecialHarness. + await checkIsHarness(results[0], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test tools')); + await checkIsHarness(results[1], SubComponentSpecialHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test tools')); + + // The rest only have a SubComponentHarness. + await checkIsHarness(results[2], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of test methods')); + await checkIsHarness(results[3], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 1')); + await checkIsHarness(results[4], SubComponentHarness, async subHarness => + expect(await subHarness.titleText()).toBe('List of other 2')); + }); + + it('should throw when multiple queries fail to match', async () => { + await expectAsyncError(() => harness.missingElementsAndHarnesses(), + 'Error: Failed to find element matching one of the following queries:' + + '\n(TestElement for element matching selector: ".not-found"),' + + '\n(SubComponentHarness with host element matching selector: "test-sub" satisfying' + + ' the constraints: title = /not found/)'); + }); }); describe('HarnessPredicate', () => { @@ -409,10 +486,27 @@ describe('TestbedHarnessEnvironment', () => { fail('Expected to throw'); } catch (e) { expect(e.message).toBe( - 'Expected to find element for SubComponentHarness matching selector: "test-sub"' + - ' (with restrictions: title = "List of test tools", item count = 4),' + - ' but none was found'); + 'Failed to find element matching one of the following queries:' + + '\n(SubComponentHarness with host element matching selector: "test-sub" satisfying' + + ' the constraints: title = "List of test tools", item count = 4)'); } }); }); }); + +async function checkIsElement(result: ComponentHarness | TestElement, selector?: string) { + expect(result instanceof ComponentHarness).toBe(false); + if (selector) { + expect(await (result as TestElement).matchesSelector(selector)).toBe(true); + } +} + +async function checkIsHarness( + result: ComponentHarness | TestElement, + harnessType: ComponentHarnessConstructor, + finalCheck?: (harness: T) => Promise) { + expect(result.constructor === harnessType).toBe(true); + if (finalCheck) { + await finalCheck(result as T); + } +} diff --git a/src/material-experimental/form-field/testing/form-field-harness.ts b/src/material-experimental/form-field/testing/form-field-harness.ts index 876d0708dad5..a774772284bd 100644 --- a/src/material-experimental/form-field/testing/form-field-harness.ts +++ b/src/material-experimental/form-field/testing/form-field-harness.ts @@ -10,11 +10,10 @@ import { ComponentHarness, ComponentHarnessConstructor, HarnessPredicate, + HarnessQuery, TestElement } from '@angular/cdk/testing'; -import { - MatFormFieldControlHarness -} from '@angular/material-experimental/form-field/testing/control'; +import {MatFormFieldControlHarness} from '@angular/material-experimental/form-field/testing/control'; import {MatInputHarness} from '@angular/material-experimental/input/testing'; import {MatSelectHarness} from '@angular/material-experimental/select/testing'; import {FormFieldHarnessFilters} from './form-field-harness-filters'; @@ -85,8 +84,7 @@ export class MatFormFieldHarness extends ComponentHarness { Promise; // Implementation of the "getControl" method overload signatures. - async getControl(type?: ComponentHarnessConstructor| - HarnessPredicate) { + async getControl(type?: HarnessQuery) { if (type) { return this.locatorForOptional(type)(); } From b4554ffb80f974e992a05faa04db7139834e3eb5 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Fri, 8 Nov 2019 19:40:22 -0800 Subject: [PATCH 2/6] fix lint and api goldens --- package.json | 2 +- .../form-field/testing/form-field-harness.ts | 2 +- tools/public_api_guard/cdk/testing.d.ts | 43 +++++++++---------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 7a6cc15b4fc7..82b38a080d45 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dev-app": "ibazel run //src/dev-app:devserver", "test": "bazel test //src/... --test_tag_filters=-e2e,-browser:firefox-local --build_tag_filters=-browser:firefox-local --build_tests_only", "test-firefox": "bazel test //src/... --test_tag_filters=-e2e,-browser:chromium-local --build_tag_filters=-browser:chromium-local --build_tests_only", - "lint": "gulp lint && yarn -s bazel:format-lint", + "lint": "yarn -s tslint && yarn -s bazel:format-lint && yarn -s ownerslint", "e2e": "bazel test //src/... --test_tag_filters=e2e", "deploy": "echo 'Not supported yet. Tracked with COMP-230'", "webdriver-manager": "webdriver-manager", diff --git a/src/material-experimental/form-field/testing/form-field-harness.ts b/src/material-experimental/form-field/testing/form-field-harness.ts index a774772284bd..9c1f5cb5cfb0 100644 --- a/src/material-experimental/form-field/testing/form-field-harness.ts +++ b/src/material-experimental/form-field/testing/form-field-harness.ts @@ -13,9 +13,9 @@ import { HarnessQuery, TestElement } from '@angular/cdk/testing'; -import {MatFormFieldControlHarness} from '@angular/material-experimental/form-field/testing/control'; import {MatInputHarness} from '@angular/material-experimental/input/testing'; import {MatSelectHarness} from '@angular/material-experimental/select/testing'; +import {MatFormFieldControlHarness} from './control'; import {FormFieldHarnessFilters} from './form-field-harness-filters'; // TODO(devversion): support datepicker harness once developed (COMP-203). diff --git a/tools/public_api_guard/cdk/testing.d.ts b/tools/public_api_guard/cdk/testing.d.ts index e520c2dd6d24..1404a0d1f806 100644 --- a/tools/public_api_guard/cdk/testing.d.ts +++ b/tools/public_api_guard/cdk/testing.d.ts @@ -15,12 +15,9 @@ export declare abstract class ComponentHarness { protected documentRootLocatorFactory(): LocatorFactory; protected forceStabilize(): Promise; host(): Promise; - protected locatorFor(selector: string): AsyncFactoryFn; - protected locatorFor(harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - protected locatorForAll(selector: string): AsyncFactoryFn; - protected locatorForAll(harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - protected locatorForOptional(selector: string): AsyncFactoryFn; - protected locatorForOptional(harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; + protected locatorFor | string)[]>(...queries: T): AsyncFactoryFn>; + protected locatorForAll | string)[]>(...queries: T): AsyncFactoryFn[]>; + protected locatorForOptional | string)[]>(...queries: T): AsyncFactoryFn | null>; protected waitForTasksOutsideAngular(): Promise; } @@ -39,28 +36,25 @@ export declare abstract class HarnessEnvironment implements HarnessLoader, Lo documentRootLocatorFactory(): LocatorFactory; abstract forceStabilize(): Promise; getAllChildLoaders(selector: string): Promise; - getAllHarnesses(harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise; + getAllHarnesses(query: HarnessQuery): Promise; protected abstract getAllRawElements(selector: string): Promise; getChildLoader(selector: string): Promise; protected abstract getDocumentRoot(): E; - getHarness(harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise; + getHarness(query: HarnessQuery): Promise; harnessLoaderFor(selector: string): Promise; harnessLoaderForAll(selector: string): Promise; harnessLoaderForOptional(selector: string): Promise; - locatorFor(selector: string): AsyncFactoryFn; - locatorFor(harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - locatorForAll(selector: string): AsyncFactoryFn; - locatorForAll(harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - locatorForOptional(selector: string): AsyncFactoryFn; - locatorForOptional(harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; + locatorFor | string)[]>(...queries: T): AsyncFactoryFn>; + locatorForAll | string)[]>(...queries: T): AsyncFactoryFn[]>; + locatorForOptional | string)[]>(...queries: T): AsyncFactoryFn | null>; abstract waitForTasksOutsideAngular(): Promise; } export interface HarnessLoader { getAllChildLoaders(selector: string): Promise; - getAllHarnesses(harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise; + getAllHarnesses(query: HarnessQuery): Promise; getChildLoader(selector: string): Promise; - getHarness(harnessType: ComponentHarnessConstructor | HarnessPredicate): Promise; + getHarness(query: HarnessQuery): Promise; } export declare class HarnessPredicate { @@ -75,6 +69,8 @@ export declare class HarnessPredicate { static stringMatches(s: string | Promise, pattern: string | RegExp): Promise; } +export declare type HarnessQuery = ComponentHarnessConstructor | HarnessPredicate; + export interface LocatorFactory { rootElement: TestElement; documentRootLocatorFactory(): LocatorFactory; @@ -82,15 +78,18 @@ export interface LocatorFactory { harnessLoaderFor(selector: string): Promise; harnessLoaderForAll(selector: string): Promise; harnessLoaderForOptional(selector: string): Promise; - locatorFor(selector: string): AsyncFactoryFn; - locatorFor(harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - locatorForAll(selector: string): AsyncFactoryFn; - locatorForAll(harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; - locatorForOptional(selector: string): AsyncFactoryFn; - locatorForOptional(harnessType: ComponentHarnessConstructor | HarnessPredicate): AsyncFactoryFn; + locatorFor | string)[]>(...queries: T): AsyncFactoryFn>; + locatorForAll | string)[]>(...queries: T): AsyncFactoryFn[]>; + locatorForOptional | string)[]>(...queries: T): AsyncFactoryFn | null>; waitForTasksOutsideAngular(): Promise; } +export declare type LocatorFnResult | string)[]> = { + [I in keyof T]: T[I] extends new (...args: any[]) => infer C ? C : T[I] extends { + harnessType: new (...args: any[]) => infer C; + } ? C : T[I] extends string ? TestElement : never; +}[number]; + export interface TestElement { blur(): Promise; clear(): Promise; From 59fbd174446e84c1b822df0cb8f938f8ac0b396e Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Mon, 11 Nov 2019 16:16:53 -0800 Subject: [PATCH 3/6] address feedback --- src/cdk/testing/component-harness.ts | 69 ++++++++++++++++---------- src/cdk/testing/harness-environment.ts | 55 ++++++++++---------- 2 files changed, 68 insertions(+), 56 deletions(-) diff --git a/src/cdk/testing/component-harness.ts b/src/cdk/testing/component-harness.ts index 76a2260187d4..9ee25576582d 100644 --- a/src/cdk/testing/component-harness.ts +++ b/src/cdk/testing/component-harness.ts @@ -25,11 +25,16 @@ export type HarnessQuery = ComponentHarnessConstructor | HarnessPredicate; /** - * The type returned by the functions that the `locatorFor*` methods create. - * Maps the input type array and creates a type union from the mapped array: - * - `ComponentHarnessConstructor<T>` maps to `T` - * - `HarnessPredicate<T>` maps to `T` - * - `string` maps to `TestElement` + * The result type obtained when searching using a particular list of queries. This type depends on + * the particular items being queried. + * - If one of the queries is for a `ComponentHarnessConstructor`, it means that the result + * might be a harness of type `C1` + * - If one of the queries is for a `HarnessPredicate`, it means that the result might be a + * harness of type `C2` + * - If one of the queries is for a `string`, it means that the result might be a `TestElement`. + * + * Since we don't know for sure which query will match, the result type if the union of the types + * for all possible results. * * e.g. * The type: @@ -113,8 +118,8 @@ export interface LocatorFactory { /** * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance * or element under the root element of this `LocatorFactory`. - * @param {...*} queries A list of queries specifying which harnesses and elements to search for: - * - A `string` searches for elements matching the selector specified by the string. + * @param queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the * given class. * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given @@ -122,7 +127,8 @@ export interface LocatorFactory { * @return An asynchronous locator function that searches for and returns a `Promise` for the * first element or harness matching the given search criteria. Matches are ordered first by * order in the DOM, and second by order in the queries list. If no matches are found, the - * `Promise` rejects. + * `Promise` rejects. The type that the `Promise` resolves to is a union of all result types for + * each query. * * e.g. Given the following DOM: `
`, and assuming * `DivHarness.hostSelector === 'div'`: @@ -136,8 +142,8 @@ export interface LocatorFactory { /** * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance * or element under the root element of this `LocatorFactory`. - * @param {...*} queries A list of queries specifying which harnesses and elements to search for: - * - A `string` searches for elements matching the selector specified by the string. + * @param queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the * given class. * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given @@ -145,7 +151,8 @@ export interface LocatorFactory { * @return An asynchronous locator function that searches for and returns a `Promise` for the * first element or harness matching the given search criteria. Matches are ordered first by * order in the DOM, and second by order in the queries list. If no matches are found, the - * `Promise` is resolved with `null`. + * `Promise` is resolved with `null`. The type that the `Promise` resolves to is a union of all + * result types for each query or null. * * e.g. Given the following DOM: `
`, and assuming * `DivHarness.hostSelector === 'div'`: @@ -159,8 +166,8 @@ export interface LocatorFactory { /** * Creates an asynchronous locator function that can be used to find `ComponentHarness` instances * or elements under the root element of this `LocatorFactory`. - * @param {...*} queries A list of queries specifying which harnesses and elements to search for: - * - A `string` searches for elements matching the selector specified by the string. + * @param queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the * given class. * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given @@ -170,7 +177,8 @@ export interface LocatorFactory { * order in the DOM, and second by order in the queries list. If an element matches more than * one `ComponentHarness` class, the locator gets an instance of each for the same element. If * an element matches multiple `string` selectors, only one `TestElement` instance is returned - * for that element. + * for that element. The type that the `Promise` resolves to is an array where each element is + * the union of all result types for each query. * * e.g. Given the following DOM: `
`, and assuming * `DivHarness.hostSelector === 'div'` and `IdIsD1Harness.hostSelector === '#d1'`: @@ -256,8 +264,8 @@ export abstract class ComponentHarness { /** * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance * or element under the host element of this `ComponentHarness`. - * @param {...*} queries A list of queries specifying which harnesses and elements to search for: - * - A `string` searches for elements matching the selector specified by the string. + * @param queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the * given class. * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given @@ -265,7 +273,8 @@ export abstract class ComponentHarness { * @return An asynchronous locator function that searches for and returns a `Promise` for the * first element or harness matching the given search criteria. Matches are ordered first by * order in the DOM, and second by order in the queries list. If no matches are found, the - * `Promise` rejects. + * `Promise` rejects. The type that the `Promise` resolves to is a union of all result types for + * each query. * * e.g. Given the following DOM: `
`, and assuming * `DivHarness.hostSelector === 'div'`: @@ -281,8 +290,8 @@ export abstract class ComponentHarness { /** * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance * or element under the host element of this `ComponentHarness`. - * @param {...*} queries A list of queries specifying which harnesses and elements to search for: - * - A `string` searches for elements matching the selector specified by the string. + * @param queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the * given class. * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given @@ -290,7 +299,8 @@ export abstract class ComponentHarness { * @return An asynchronous locator function that searches for and returns a `Promise` for the * first element or harness matching the given search criteria. Matches are ordered first by * order in the DOM, and second by order in the queries list. If no matches are found, the - * `Promise` is resolved with `null`. + * `Promise` is resolved with `null`. The type that the `Promise` resolves to is a union of all + * result types for each query or null. * * e.g. Given the following DOM: `
`, and assuming * `DivHarness.hostSelector === 'div'`: @@ -306,8 +316,8 @@ export abstract class ComponentHarness { /** * Creates an asynchronous locator function that can be used to find `ComponentHarness` instances * or elements under the host element of this `ComponentHarness`. - * @param {...*} queries A list of queries specifying which harnesses and elements to search for: - * - A `string` searches for elements matching the selector specified by the string. + * @param queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the * given class. * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given @@ -317,7 +327,8 @@ export abstract class ComponentHarness { * order in the DOM, and second by order in the queries list. If an element matches more than * one `ComponentHarness` class, the locator gets an instance of each for the same element. If * an element matches multiple `string` selectors, only one `TestElement` instance is returned - * for that element. + * for that element. The type that the `Promise` resolves to is an array where each element is + * the union of all result types for each query. * * e.g. Given the following DOM: `
`, and assuming * `DivHarness.hostSelector === 'div'` and `IdIsD1Harness.hostSelector === '#d1'`: @@ -487,8 +498,12 @@ function _valueAsString(value: unknown) { return 'undefined'; } // `JSON.stringify` doesn't handle RegExp properly, so we need a custom replacer. - return JSON.stringify(value, (_, v) => - v instanceof RegExp ? `/${v.toString()}/` : - typeof v === 'string' ? v.replace('/\//g', '\\/') : v - ).replace(/"\/\//g, '\\/').replace(/\/\/"/g, '\\/').replace(/\\\//g, '/'); + try { + return JSON.stringify(value, (_, v) => + v instanceof RegExp ? `/${v.toString()}/` : + typeof v === 'string' ? v.replace('/\//g', '\\/') : v + ).replace(/"\/\//g, '\\/').replace(/\/\/"/g, '\\/').replace(/\\\//g, '/'); + } catch (e) { + return '{...}'; + } } diff --git a/src/cdk/testing/harness-environment.ts b/src/cdk/testing/harness-environment.ts index 7de6f5707276..5c44c667069f 100644 --- a/src/cdk/testing/harness-environment.ts +++ b/src/cdk/testing/harness-environment.ts @@ -137,7 +137,7 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac // Combine all of the queries into one large comma-delimited selector and use it to get all raw // elements matching any of the individual queries. const rawElements = await this.getAllRawElements( - [...elementQueries, ...harnessQueries.map(pred => pred.getSelector())].join(',')); + [...elementQueries, ...harnessQueries.map(predicate => predicate.getSelector())].join(',')); // If there are only element queries, we can just return a `TestElement` for each raw element // that was matched. if (harnessQueries.length === 0) { @@ -168,12 +168,13 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac rawElements: E[], harnessType: ComponentHarnessConstructor, harnessPredicates: HarnessPredicate[]): Promise { return ( - await Promise.all(rawElements.map(rawElement => { + await Promise.all(rawElements.map(async rawElement => { const harness = this.createComponentHarness(harnessType, rawElement); - // Get a list of boolean results by comparing the harness against each predicate, then - // check if the harness matched any of them. - return Promise.all(harnessPredicates.map(pred => pred.evaluate(harness))) - .then(matches => matches.some(isMatch => isMatch) ? harness : null); + // Get a list of boolean results by comparing the harness against each predicate. + const predicateResults = + await Promise.all(harnessPredicates.map(predicate => predicate.evaluate(harness))); + // Check if the harness matched any of the predicates. + return predicateResults.some(isMatch => isMatch) ? harness : null; })) ).filter((harness): harness is T => !!harness); } @@ -185,16 +186,17 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac private async _matchRawElementsToHarnessesAndTestElements)[]>(rawElements: E[], allQueries: T): Promise<(LocatorFnResult)[]> { - return Promise.all(rawElements.map(rawElement => { + const perElementMatches = await Promise.all(rawElements.map(async rawElement => { const testElement = this.createTestElement(rawElement); - return Promise.all( + const allResultsForElement = await Promise.all( // For each query, get `null` if it doesn't match, or a `TestElement` or // `ComponentHarness` as appropriate if it does match. This gives us everything that // matches the current raw element, but it may contain duplicate entries (e.g. multiple // `TestElement` or multiple `ComponentHarness` of the same type. - allQueries.map(query => this._getQueryResultForElement(query, rawElement, testElement)) - ).then(allResultsForElement => _removeDuplicateQueryResults(allResultsForElement)); - })).then(perElementMatches => ([] as any).concat(...perElementMatches)); + allQueries.map(query => this._getQueryResultForElement(query, rawElement, testElement))); + return _removeDuplicateQueryResults(allResultsForElement); + })); + return ([] as any).concat(...perElementMatches); } /** @@ -205,16 +207,13 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac query: string | HarnessPredicate, rawElement: E, testElement: TestElement): Promise { if (typeof query === 'string') { - return testElement.matchesSelector(query) - .then(selectorMatches => selectorMatches ? testElement : null); + return (await testElement.matchesSelector(query) ? testElement : null); } - return testElement.matchesSelector(query.getSelector()).then(selectorMatches => { - if (selectorMatches) { - const harness = this.createComponentHarness(query.harnessType, rawElement); - return query.evaluate(harness).then(predMatches => predMatches ? harness : null); - } - return null; - }); + if (await testElement.matchesSelector(query.getSelector())) { + const harness = this.createComponentHarness(query.harnessType, rawElement); + return (await query.evaluate(harness)) ? harness : null; + } + return null; } } @@ -235,10 +234,10 @@ function _parseQueries | string)[]>(queries: T): allQueries.push(query); elementQueries.push(query); } else { - const pred = query instanceof HarnessPredicate ? query : new HarnessPredicate(query, {}); - allQueries.push(pred); - harnessQueries.push(pred); - harnessTypes.add(pred.harnessType); + const predicate = query instanceof HarnessPredicate ? query : new HarnessPredicate(query, {}); + allQueries.push(predicate); + harnessQueries.push(predicate); + harnessTypes.add(predicate.harnessType); } } @@ -263,11 +262,9 @@ async function _removeDuplicateQueryResults Date: Tue, 12 Nov 2019 11:39:25 -0800 Subject: [PATCH 4/6] address feedback --- src/cdk/testing/harness-environment.ts | 83 ++++++++++---------------- 1 file changed, 31 insertions(+), 52 deletions(-) diff --git a/src/cdk/testing/harness-environment.ts b/src/cdk/testing/harness-environment.ts index 5c44c667069f..1f771e1e8b3f 100644 --- a/src/cdk/testing/harness-environment.ts +++ b/src/cdk/testing/harness-environment.ts @@ -20,9 +20,19 @@ import {TestElement} from './test-element'; /** Parsed form of the queries passed to the `locatorFor*` methods. */ type ParsedQueries = { + /** The full list of queries, in their original order. */ allQueries: (string | HarnessPredicate)[], + /** + * A filtered view of `allQueries` containing only the queries that are looking for a + * `ComponentHarness` + */ harnessQueries: HarnessPredicate[], + /** + * A filtered view of `allQueries` containing only the queries that are looking for a + * `TestElement` + */ elementQueries: string[], + /** The set of all `ComponentHarness` subclasses represented in the original query list. */ harnessTypes: Set>, }; @@ -130,62 +140,26 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac */ protected abstract getAllRawElements(selector: string): Promise; - /** Gets a list of matching harnesses and test elements for a given query list. */ + /** + * Matches the given raw elements with the given list of element and harness queries to produce a + * list of matched harnesses and test elements. + */ private async _getAllHarnessesAndTestElements | string)[]>( queries: T): Promise[]> { const {allQueries, harnessQueries, elementQueries, harnessTypes} = _parseQueries(queries); + // Combine all of the queries into one large comma-delimited selector and use it to get all raw // elements matching any of the individual queries. const rawElements = await this.getAllRawElements( [...elementQueries, ...harnessQueries.map(predicate => predicate.getSelector())].join(',')); - // If there are only element queries, we can just return a `TestElement` for each raw element - // that was matched. - if (harnessQueries.length === 0) { - return this._matchRawElementsToTestElements(rawElements) as LocatorFnResult[]; - } - // If the queries contain only searches for a single harness type, we can just create instances - // of that class for all raw elements and filter out the ones that don't match any query. - if (elementQueries.length === 0 && harnessTypes.size === 1) { - return this._matchRawElementsToHarnessType( - rawElements, harnessQueries[0].harnessType, harnessQueries); - } - // Otherwise the queries are come combination of different ComponentHarness classes and string - // selectors. In this case we need to do extra matching ahead of time to determine which - // selectors were responsible for finding which raw elements. - return this._matchRawElementsToHarnessesAndTestElements(rawElements, allQueries); - } - - /** Matches the given raw elements with test elements. */ - private _matchRawElementsToTestElements(rawElements: E[]): TestElement[] { - return rawElements.map(rawElement => this.createTestElement(rawElement)); - } - /** - * Matches the given raw elements with a list of harness predicates that all produce the same type - * of harness to produce a list of matched harnesses. - */ - private async _matchRawElementsToHarnessType( - rawElements: E[], harnessType: ComponentHarnessConstructor, - harnessPredicates: HarnessPredicate[]): Promise { - return ( - await Promise.all(rawElements.map(async rawElement => { - const harness = this.createComponentHarness(harnessType, rawElement); - // Get a list of boolean results by comparing the harness against each predicate. - const predicateResults = - await Promise.all(harnessPredicates.map(predicate => predicate.evaluate(harness))); - // Check if the harness matched any of the predicates. - return predicateResults.some(isMatch => isMatch) ? harness : null; - })) - ).filter((harness): harness is T => !!harness); - } + // If every query is searching for the same harness subclass, we know every result corresponds + // to an instance of that subclass. Likewise, if every query is for a `TestElement`, we know + // every result corresponds to a `TestElement`. Otherwise we need to verify which result was + // found by which selector so it can be matched to the appropriate instance. + const skipSelectorCheck = (elementQueries.length === 0 && harnessTypes.size === 1) || + harnessQueries.length === 0; - /** - * Matches the given raw elements with the given list of element and harness queries to produce a - * list of matched harnesses and test elements. - */ - private async _matchRawElementsToHarnessesAndTestElements)[]>(rawElements: E[], allQueries: T): - Promise<(LocatorFnResult)[]> { const perElementMatches = await Promise.all(rawElements.map(async rawElement => { const testElement = this.createTestElement(rawElement); const allResultsForElement = await Promise.all( @@ -193,7 +167,8 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac // `ComponentHarness` as appropriate if it does match. This gives us everything that // matches the current raw element, but it may contain duplicate entries (e.g. multiple // `TestElement` or multiple `ComponentHarness` of the same type. - allQueries.map(query => this._getQueryResultForElement(query, rawElement, testElement))); + allQueries.map(query => + this._getQueryResultForElement(query, rawElement, testElement, skipSelectorCheck))); return _removeDuplicateQueryResults(allResultsForElement); })); return ([] as any).concat(...perElementMatches); @@ -204,12 +179,12 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac * `TestElement` or `ComponentHarness`, if it does not, return null. */ private async _getQueryResultForElement( - query: string | HarnessPredicate, rawElement: E, testElement: TestElement): - Promise { + query: string | HarnessPredicate, rawElement: E, testElement: TestElement, + skipSelectorCheck: boolean): Promise { if (typeof query === 'string') { - return (await testElement.matchesSelector(query) ? testElement : null); + return ((skipSelectorCheck || await testElement.matchesSelector(query)) ? testElement : null); } - if (await testElement.matchesSelector(query.getSelector())) { + if (skipSelectorCheck || await testElement.matchesSelector(query.getSelector())) { const harness = this.createComponentHarness(query.harnessType, rawElement); return (await query.evaluate(harness)) ? harness : null; } @@ -281,11 +256,13 @@ async function _assertResultFound(results: Promise, queryDescriptions: s return result; } +/** Gets a list of description strings from a list of queries. */ function _getDescriptionForLocatorForQueries(queries: (string | HarnessQuery)[]) { return queries.map(query => typeof query === 'string' ? _getDescriptionForTestElementQuery(query) : _getDescriptionForComponentHarnessQuery(query)); } +/** Gets a description string for a `ComponentHarness` query. */ function _getDescriptionForComponentHarnessQuery(query: HarnessQuery) { const harnessPredicate = query instanceof HarnessPredicate ? query : new HarnessPredicate(query, {}); @@ -296,10 +273,12 @@ function _getDescriptionForComponentHarnessQuery(query: HarnessQuery) { ` satisfying the constraints: ${harnessPredicate.getDescription()}` : ''); } +/** Gets a description string for a `TestElement` query. */ function _getDescriptionForTestElementQuery(selector: string) { return `TestElement for element matching selector: "${selector}"`; } +/** Gets a description string for a `HarnessLoader` query. */ function _getDescriptionForHarnessLoaderQuery(selector: string) { return `HarnessLoader for element matching selector: "${selector}"`; } From 77e877fa5cf68e8078b51b48f3eb92cbd3371991 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 12 Nov 2019 12:48:47 -0800 Subject: [PATCH 5/6] address comments --- src/cdk/testing/harness-environment.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cdk/testing/harness-environment.ts b/src/cdk/testing/harness-environment.ts index 1f771e1e8b3f..bd3af4dcee3a 100644 --- a/src/cdk/testing/harness-environment.ts +++ b/src/cdk/testing/harness-environment.ts @@ -176,11 +176,13 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac /** * Check whether the given query matches the given element, if it does return the matched - * `TestElement` or `ComponentHarness`, if it does not, return null. + * `TestElement` or `ComponentHarness`, if it does not, return null. In cases where the caller + * knows for sure that the query matches the element's selector, `skipSelectorCheck` can be used + * to skip verification and optimize performance. */ private async _getQueryResultForElement( query: string | HarnessPredicate, rawElement: E, testElement: TestElement, - skipSelectorCheck: boolean): Promise { + skipSelectorCheck: boolean = false): Promise { if (typeof query === 'string') { return ((skipSelectorCheck || await testElement.matchesSelector(query)) ? testElement : null); } From 7667e0669b5d96a399a9988ec2af54c32bd0d0b7 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 12 Nov 2019 13:06:41 -0800 Subject: [PATCH 6/6] address comments --- src/cdk/testing/component-harness.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cdk/testing/component-harness.ts b/src/cdk/testing/component-harness.ts index 9ee25576582d..da8484e64ac1 100644 --- a/src/cdk/testing/component-harness.ts +++ b/src/cdk/testing/component-harness.ts @@ -503,7 +503,9 @@ function _valueAsString(value: unknown) { v instanceof RegExp ? `/${v.toString()}/` : typeof v === 'string' ? v.replace('/\//g', '\\/') : v ).replace(/"\/\//g, '\\/').replace(/\/\/"/g, '\\/').replace(/\\\//g, '/'); - } catch (e) { + } catch { + // `JSON.stringify` will throw if the object is cyclical, + // in this case the best we can do is report the value as `{...}`. return '{...}'; } }