diff --git a/src/cdk/testing/component-harness.ts b/src/cdk/testing/component-harness.ts index f8b167def6b9..02793fe249ec 100644 --- a/src/cdk/testing/component-harness.ts +++ b/src/cdk/testing/component-harness.ts @@ -106,6 +106,17 @@ export interface HarnessLoader { */ getHarnessOrNull(query: HarnessQuery): Promise; + /** + * Searches for an instance of the component corresponding to the given harness type under the + * `HarnessLoader`'s root element, and returns a `ComponentHarness` for the instance on the page + * at the given index. If no matching component exists at that index, an error is thrown. + * @param query A query for a harness to create + * @param index The zero-indexed offset of the matching component instance to return + * @return An instance of the given harness type. + * @throws If a matching component instance can't be found at the given index. + */ + getHarnessAtIndex(query: HarnessQuery, index: number): 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. @@ -114,6 +125,14 @@ export interface HarnessLoader { */ getAllHarnesses(query: HarnessQuery): Promise; + /** + * Searches for all instances of the component corresponding to the given harness type under the + * `HarnessLoader`'s root element, and returns the total count of all matching components. + * @param query A query for a harness to create + * @return An integer indicating the number of instances that were found. + */ + countHarnesses(query: HarnessQuery): Promise; + /** * Searches for an instance of the component corresponding to the given harness type under the * `HarnessLoader`'s root element, and returns a boolean indicating if any were found. @@ -425,10 +444,21 @@ export abstract class ContentContainerComponentHarness( + query: HarnessQuery, + index: number, + ): Promise { + return (await this.getRootHarnessLoader()).getHarnessAtIndex(query, index); + } + async getAllHarnesses(query: HarnessQuery): Promise { return (await this.getRootHarnessLoader()).getAllHarnesses(query); } + async countHarnesses(query: HarnessQuery): Promise { + return (await this.getRootHarnessLoader()).countHarnesses(query); + } + async hasHarness(query: HarnessQuery): Promise { return (await this.getRootHarnessLoader()).hasHarness(query); } diff --git a/src/cdk/testing/harness-environment.ts b/src/cdk/testing/harness-environment.ts index 647cfab56c78..16b3816bd60d 100644 --- a/src/cdk/testing/harness-environment.ts +++ b/src/cdk/testing/harness-environment.ts @@ -122,11 +122,31 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac return this.locatorForOptional(query)(); } + // Implemented as part of the `HarnessLoader` interface. + async getHarnessAtIndex( + query: HarnessQuery, + offset: number, + ): Promise { + if (offset < 0) { + throw Error('Index must not be negative'); + } + const harnesses = await this.locatorForAll(query)(); + if (offset >= harnesses.length) { + throw Error(`No harness was located at index ${offset}`); + } + return harnesses[offset]; + } + // Implemented as part of the `HarnessLoader` interface. getAllHarnesses(query: HarnessQuery): Promise { return this.locatorForAll(query)(); } + // Implemented as part of the `HarnessLoader` interface. + async countHarnesses(query: HarnessQuery): Promise { + return (await this.locatorForAll(query)()).length; + } + // Implemented as part of the `HarnessLoader` interface. async hasHarness(query: HarnessQuery): Promise { return (await this.locatorForOptional(query)()) !== null; diff --git a/src/cdk/testing/test-harnesses.md b/src/cdk/testing/test-harnesses.md index 73866625591e..91650836a81d 100644 --- a/src/cdk/testing/test-harnesses.md +++ b/src/cdk/testing/test-harnesses.md @@ -134,9 +134,12 @@ are used to create `ComponentHarness` instances for elements under this root ele | `getChildLoader(selector: string): Promise` | Searches for an element matching the given selector below the root element of this `HarnessLoader`, and returns a new `HarnessLoader` rooted at the first matching element | | `getAllChildLoaders(selector: string): Promise` | Acts like `getChildLoader`, but returns an array of `HarnessLoader` instances, one for each matching element, rather than just the first matching element | | `getHarness(harnessType: ComponentHarnessConstructor \| HarnessPredicate): Promise` | Searches for an instance of the given `ComponentHarness` class or `HarnessPredicate` below the root element of this `HarnessLoader` and returns an instance of the harness corresponding to the first matching element | +| `getHarnessAtIndex(harnessType: ComponentHarnessConstructor \| HarnessPredicate, index: number): Promise` | Acts like `getHarness`, but returns an instance of the harness corresponding to the matching element with the given index (zero-indexed) | | `getAllHarnesses(harnessType: ComponentHarnessConstructor \| HarnessPredicate): Promise` | Acts like `getHarness`, but returns an array of harness instances, one for each matching element, rather than just the first matching element | +| `countHarnesses(harnessType: ComponentHarnessConstructor \| HarnessPredicate): Promise` | Counts the number of instances of the given `ComponentHarness` class or `HarnessPredicate` below the root element of this `HarnessLoader`, and returns the result. | +| `hasHarness(harnessType: ComponentHarnessConstructor \| HarnessPredicate): Promise` | Returns true if an instance of the given `ComponentHarness` class or `HarnessPredicate` exists below the root element of this `HarnessLoader` | -Calls to `getHarness` and `getAllHarnesses` can either take `ComponentHarness` subclass or a +Calls to the harness functions can either take `ComponentHarness` subclass or a `HarnessPredicate`. `HarnessPredicate` applies additional restrictions to the search (e.g. searching for a button that has some particular text, etc). The [details of `HarnessPredicate`](#filtering-harness-instances-with-harnesspredicate) are discussed in diff --git a/src/material/datepicker/testing/date-range-input-harness.spec.ts b/src/material/datepicker/testing/date-range-input-harness.spec.ts index 5396975fe711..cb1a9b5356d9 100644 --- a/src/material/datepicker/testing/date-range-input-harness.spec.ts +++ b/src/material/datepicker/testing/date-range-input-harness.spec.ts @@ -11,6 +11,7 @@ import { MatEndDate, MatStartDate, } from '../../datepicker'; +import {MatFormFieldModule} from '../../form-field'; import {MatCalendarHarness} from './calendar-harness'; import { MatDateRangeInputHarness, @@ -34,7 +35,14 @@ describe('matDateRangeInputHarness', () => { it('should load all date range input harnesses', async () => { const inputs = await loader.getAllHarnesses(MatDateRangeInputHarness); - expect(inputs.length).toBe(2); + expect(inputs.length).toBe(3); + }); + + it('should load date range input with a specific label', async () => { + const inputs = await loader.getAllHarnesses( + MatDateRangeInputHarness.with({label: 'Date range'}), + ); + expect(inputs.length).toBe(1); }); it('should get whether the input is disabled', async () => { @@ -261,6 +269,14 @@ describe('matDateRangeInputHarness', () => { + + + Date range + + + + + `, imports: [ MatNativeDateModule, @@ -268,6 +284,7 @@ describe('matDateRangeInputHarness', () => { MatStartDate, MatEndDate, MatDateRangePicker, + MatFormFieldModule, FormsModule, ], }) diff --git a/src/material/datepicker/testing/date-range-input-harness.ts b/src/material/datepicker/testing/date-range-input-harness.ts index c2a12d8dc040..2b7dfb135b10 100644 --- a/src/material/datepicker/testing/date-range-input-harness.ts +++ b/src/material/datepicker/testing/date-range-input-harness.ts @@ -48,6 +48,8 @@ export class MatEndDateHarness extends MatDatepickerInputHarnessBase { export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase { static hostSelector = '.mat-date-range-input'; + private readonly floatingLabelSelector = '.mdc-floating-label'; + /** * Gets a `HarnessPredicate` that can be used to search for a `MatDateRangeInputHarness` * that meets certain criteria. @@ -57,11 +59,13 @@ export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase { static with( options: DateRangeInputHarnessFilters = {}, ): HarnessPredicate { - return new HarnessPredicate(MatDateRangeInputHarness, options).addOption( - 'value', - options.value, - (harness, value) => HarnessPredicate.stringMatches(harness.getValue(), value), - ); + return new HarnessPredicate(MatDateRangeInputHarness, options) + .addOption('value', options.value, (harness, value) => + HarnessPredicate.stringMatches(harness.getValue(), value), + ) + .addOption('label', options.label, (harness, label) => { + return HarnessPredicate.stringMatches(harness.getLabel(), label); + }); } /** Gets the combined value of the start and end inputs, including the separator. */ @@ -87,6 +91,31 @@ export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase { return this.locatorFor(MatEndDateHarness)(); } + /** Gets the floating label text for the range input, if it exists. */ + async getLabel(): Promise { + // Copied from MatFormFieldControlHarness since this class cannot extend two classes + const documentRootLocator = await this.documentRootLocatorFactory(); + const labelId = await (await this.host()).getAttribute('aria-labelledby'); + const hostId = await (await this.host()).getAttribute('id'); + + if (labelId) { + // First option, try to fetch the label using the `aria-labelledby` + // attribute. + const labelEl = await await documentRootLocator.locatorForOptional( + `${this.floatingLabelSelector}[id="${labelId}"]`, + )(); + return labelEl ? labelEl.text() : null; + } else if (hostId) { + // Fallback option, try to match the id of the input with the `for` + // attribute of the label. + const labelEl = await await documentRootLocator.locatorForOptional( + `${this.floatingLabelSelector}[for="${hostId}"]`, + )(); + return labelEl ? labelEl.text() : null; + } + return null; + } + /** Gets the separator text between the values of the two inputs. */ async getSeparator(): Promise { return (await this.locatorFor('.mat-date-range-input-separator')()).text(); diff --git a/src/material/datepicker/testing/datepicker-harness-filters.ts b/src/material/datepicker/testing/datepicker-harness-filters.ts index 4245ad9625ef..90e750fd7ad2 100644 --- a/src/material/datepicker/testing/datepicker-harness-filters.ts +++ b/src/material/datepicker/testing/datepicker-harness-filters.ts @@ -6,10 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ +import {MatFormFieldControlHarnessFilters} from '@angular/material/form-field/testing/control'; import {BaseHarnessFilters} from '@angular/cdk/testing'; /** A set of criteria that can be used to filter a list of datepicker input instances. */ -export interface DatepickerInputHarnessFilters extends BaseHarnessFilters { +export interface DatepickerInputHarnessFilters extends MatFormFieldControlHarnessFilters { /** Filters based on the value of the input. */ value?: string | RegExp; /** Filters based on the placeholder text of the input. */ @@ -43,7 +44,7 @@ export interface CalendarCellHarnessFilters extends BaseHarnessFilters { } /** A set of criteria that can be used to filter a list of date range input instances. */ -export interface DateRangeInputHarnessFilters extends BaseHarnessFilters { +export interface DateRangeInputHarnessFilters extends MatFormFieldControlHarnessFilters { /** Filters based on the value of the input. */ value?: string | RegExp; } diff --git a/src/material/datepicker/testing/datepicker-input-harness-base.ts b/src/material/datepicker/testing/datepicker-input-harness-base.ts index 6e736928836f..cfe4de956154 100644 --- a/src/material/datepicker/testing/datepicker-input-harness-base.ts +++ b/src/material/datepicker/testing/datepicker-input-harness-base.ts @@ -7,7 +7,7 @@ */ import {ComponentHarnessConstructor, HarnessPredicate} from '@angular/cdk/testing'; -import {MatFormFieldControlHarness} from '../../form-field/testing/control'; +import {MatFormFieldControlHarness} from '@angular/material/form-field/testing/control'; import {DatepickerInputHarnessFilters} from './datepicker-harness-filters'; /** Sets up the filter predicates for a datepicker input harness. */ @@ -21,6 +21,9 @@ export function getInputPredicate( }) .addOption('placeholder', options.placeholder, (harness, placeholder) => { return HarnessPredicate.stringMatches(harness.getPlaceholder(), placeholder); + }) + .addOption('label', options.label, (harness, label) => { + return HarnessPredicate.stringMatches(harness.getLabel(), label); }); } @@ -36,6 +39,11 @@ export abstract class MatDatepickerInputHarnessBase extends MatFormFieldControlH return (await this.host()).getProperty('required'); } + /** Gets the floating label text for the input, if it exists. */ + async getLabel(): Promise { + return await this._getFloatingLabelText(); + } + /** Gets the value of the input. */ async getValue(): Promise { // The "value" property of the native input is always defined. diff --git a/src/material/datepicker/testing/datepicker-input-harness.spec.ts b/src/material/datepicker/testing/datepicker-input-harness.spec.ts index 8cf8739c62a8..72aa47c88353 100644 --- a/src/material/datepicker/testing/datepicker-input-harness.spec.ts +++ b/src/material/datepicker/testing/datepicker-input-harness.spec.ts @@ -5,6 +5,8 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {FormsModule} from '@angular/forms'; import {DateAdapter, MATERIAL_ANIMATIONS, MatNativeDateModule} from '../../core'; import {MatDatepickerModule} from '../../datepicker'; +import {MatFormFieldModule} from '../../form-field'; +import {MatInputModule} from '../../input'; import {MatCalendarHarness} from './calendar-harness'; import {MatDatepickerInputHarness} from './datepicker-input-harness'; @@ -27,6 +29,13 @@ describe('MatDatepickerInputHarness', () => { expect(inputs.length).toBe(2); }); + it('should load datepicker input with a specific label', async () => { + const selects = await loader.getAllHarnesses( + MatDatepickerInputHarness.with({label: 'Pick a date'}), + ); + expect(selects.length).toBe(1); + }); + it('should filter inputs based on their value', async () => { fixture.componentInstance.date = new Date(2020, 0, 1, 12, 0, 0); fixture.changeDetectorRef.markForCheck(); @@ -187,21 +196,31 @@ describe('MatDatepickerInputHarness', () => { @Component({ template: ` - - + + Pick a date + + + + `, - imports: [MatNativeDateModule, MatDatepickerModule, FormsModule], + imports: [ + MatNativeDateModule, + MatDatepickerModule, + MatFormFieldModule, + MatInputModule, + FormsModule, + ], }) class DatepickerInputHarnessTest { date: Date | null = null; diff --git a/src/material/form-field/testing/control/form-field-control-harness-filters.ts b/src/material/form-field/testing/control/form-field-control-harness-filters.ts new file mode 100644 index 000000000000..2959983406ab --- /dev/null +++ b/src/material/form-field/testing/control/form-field-control-harness-filters.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {BaseHarnessFilters} from '@angular/cdk/testing'; + +/** + * A set of criteria shared by any class derived from `MatFormFieldControlHarness`, that can be + * used to filter a list of those components. + */ +export interface MatFormFieldControlHarnessFilters extends BaseHarnessFilters { + /** Filters based on the text of the form field's floating label. */ + label?: string | RegExp; +} diff --git a/src/material/form-field/testing/control/form-field-control-harness.ts b/src/material/form-field/testing/control/form-field-control-harness.ts index 518f893a29dd..576a31601ce7 100644 --- a/src/material/form-field/testing/control/form-field-control-harness.ts +++ b/src/material/form-field/testing/control/form-field-control-harness.ts @@ -12,4 +12,30 @@ import {ComponentHarness} from '@angular/cdk/testing'; * Base class for custom form-field control harnesses. Harnesses for * custom controls with form-fields need to implement this interface. */ -export abstract class MatFormFieldControlHarness extends ComponentHarness {} +export abstract class MatFormFieldControlHarness extends ComponentHarness { + private readonly floatingLabelSelector = '.mdc-floating-label'; + + /** Gets the text content of the floating label, if it exists. */ + protected async _getFloatingLabelText(): Promise { + const documentRootLocator = await this.documentRootLocatorFactory(); + const labelId = await (await this.host()).getAttribute('aria-labelledby'); + const hostId = await (await this.host()).getAttribute('id'); + + if (labelId) { + // First option, try to fetch the label using the `aria-labelledby` + // attribute. + const labelEl = await await documentRootLocator.locatorForOptional( + `${this.floatingLabelSelector}[id="${labelId}"]`, + )(); + return labelEl ? labelEl.text() : null; + } else if (hostId) { + // Fallback option, try to match the id of the input with the `for` + // attribute of the label. + const labelEl = await await documentRootLocator.locatorForOptional( + `${this.floatingLabelSelector}[for="${hostId}"]`, + )(); + return labelEl ? labelEl.text() : null; + } + return null; + } +} diff --git a/src/material/form-field/testing/control/index.ts b/src/material/form-field/testing/control/index.ts index e289ebb6c6f2..4454049bc88a 100644 --- a/src/material/form-field/testing/control/index.ts +++ b/src/material/form-field/testing/control/index.ts @@ -7,3 +7,4 @@ */ export * from './form-field-control-harness'; +export * from './form-field-control-harness-filters'; diff --git a/src/material/input/testing/input-harness-filters.ts b/src/material/input/testing/input-harness-filters.ts index c68b0d626708..1635877e6ae9 100644 --- a/src/material/input/testing/input-harness-filters.ts +++ b/src/material/input/testing/input-harness-filters.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import {BaseHarnessFilters} from '@angular/cdk/testing'; +import {MatFormFieldControlHarnessFilters} from '@angular/material/form-field/testing/control'; /** A set of criteria that can be used to filter a list of `MatInputHarness` instances. */ -export interface InputHarnessFilters extends BaseHarnessFilters { +export interface InputHarnessFilters extends MatFormFieldControlHarnessFilters { /** Filters based on the value of the input. */ value?: string | RegExp; /** Filters based on the placeholder text of the input. */ diff --git a/src/material/input/testing/input-harness.spec.ts b/src/material/input/testing/input-harness.spec.ts index e4d95ee6f4de..3d93514de9fa 100644 --- a/src/material/input/testing/input-harness.spec.ts +++ b/src/material/input/testing/input-harness.spec.ts @@ -18,8 +18,7 @@ describe('MatInputHarness', () => { }); it('should load all input harnesses', async () => { - const inputs = await loader.getAllHarnesses(MatInputHarness); - expect(inputs.length).toBe(7); + expect(await loader.countHarnesses(MatInputHarness)).toBe(7); }); it('should load input with specific id', async () => { @@ -39,6 +38,11 @@ describe('MatInputHarness', () => { expect(inputs.length).toBe(1); }); + it('should load input with a specific label', async () => { + const inputs = await loader.getAllHarnesses(MatInputHarness.with({label: 'Favorite food'})); + expect(inputs.length).toBe(1); + }); + it('should load input with a value that matches a regex', async () => { const inputs = await loader.getAllHarnesses(MatInputHarness.with({value: /shi$/})); expect(inputs.length).toBe(1); @@ -231,6 +235,7 @@ describe('MatInputHarness', () => { @Component({ template: ` + Favorite food diff --git a/src/material/input/testing/input-harness.ts b/src/material/input/testing/input-harness.ts index 61e087f01cbb..dbf4c0c0b397 100644 --- a/src/material/input/testing/input-harness.ts +++ b/src/material/input/testing/input-harness.ts @@ -7,12 +7,14 @@ */ import {HarnessPredicate, parallel} from '@angular/cdk/testing'; -import {MatFormFieldControlHarness} from '../../form-field/testing/control'; +import {MatFormFieldControlHarness} from '@angular/material/form-field/testing/control'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {InputHarnessFilters} from './input-harness-filters'; /** Harness for interacting with a standard Material inputs in tests. */ export class MatInputHarness extends MatFormFieldControlHarness { + private readonly _documentRootLocator = this.documentRootLocatorFactory(); + // TODO: We do not want to handle `select` elements with `matNativeControl` because // not all methods of this harness work reasonably for native select elements. // For more details. See: https://github.com/angular/components/pull/18221. @@ -31,6 +33,9 @@ export class MatInputHarness extends MatFormFieldControlHarness { }) .addOption('placeholder', options.placeholder, (harness, placeholder) => { return HarnessPredicate.stringMatches(harness.getPlaceholder(), placeholder); + }) + .addOption('label', options.label, (harness, label) => { + return HarnessPredicate.stringMatches(harness.getLabel(), label); }); } @@ -94,6 +99,11 @@ export class MatInputHarness extends MatFormFieldControlHarness { return await (await this.host()).getProperty('id'); } + /** Gets the floating label text for the input, if it exists. */ + async getLabel(): Promise { + return await this._getFloatingLabelText(); + } + /** * Focuses the input and returns a promise that indicates when the * action is complete. diff --git a/src/material/input/testing/native-select-harness-filters.ts b/src/material/input/testing/native-select-harness-filters.ts index 438549ff0cb4..e44566192d32 100644 --- a/src/material/input/testing/native-select-harness-filters.ts +++ b/src/material/input/testing/native-select-harness-filters.ts @@ -7,9 +7,10 @@ */ import {BaseHarnessFilters} from '@angular/cdk/testing'; +import {MatFormFieldControlHarnessFilters} from '@angular/material/form-field/testing/control'; /** A set of criteria that can be used to filter a list of `MatNativeSelectHarness` instances. */ -export interface NativeSelectHarnessFilters extends BaseHarnessFilters {} +export interface NativeSelectHarnessFilters extends MatFormFieldControlHarnessFilters {} /** A set of criteria that can be used to filter a list of `MatNativeOptionHarness` instances. */ export interface NativeOptionHarnessFilters extends BaseHarnessFilters { diff --git a/src/material/input/testing/native-select-harness.spec.ts b/src/material/input/testing/native-select-harness.spec.ts index 856b3c2739ef..a3492f0e2460 100644 --- a/src/material/input/testing/native-select-harness.spec.ts +++ b/src/material/input/testing/native-select-harness.spec.ts @@ -21,6 +21,13 @@ describe('MatNativeSelectHarness', () => { expect(selects.length).toBe(2); }); + it('should load select with a specific label', async () => { + const inputs = await loader.getAllHarnesses( + MatNativeSelectHarness.with({label: 'Favorite food'}), + ); + expect(inputs.length).toBe(1); + }); + it('should get the id of a select', async () => { const selects = await loader.getAllHarnesses(MatNativeSelectHarness); expect(await parallel(() => selects.map(select => select.getId()))).toEqual(['food', 'drink']); @@ -187,6 +194,7 @@ describe('MatNativeSelectHarness', () => { @Component({ template: ` + Favorite food