From 7201eeaff9b859a2e5811638034d04343de3117f Mon Sep 17 00:00:00 2001 From: Alice Q Date: Fri, 25 Apr 2025 21:37:27 +0000 Subject: [PATCH 1/4] feat(material/testing): Add 'label' filter to form field control harnesses This facilitates testing by making it possible to fetch certain harnesses using their floating label text (mat-label). Previously, the user would have to locate the harness using an id or class, or by calling MatFormFieldHarness.getControl(). This affects the following harnesses: - MatInputHarness - MatSelectHarness - MatNativeSelectHarness - MatDatepickerInputHarness - MatDateRangeInputHarness Tests via unit tests --- .../testing/date-range-input-harness.spec.ts | 19 +++++++- .../testing/date-range-input-harness.ts | 39 +++++++++++++--- .../testing/datepicker-harness-filters.ts | 5 ++- .../testing/datepicker-input-harness-base.ts | 10 ++++- .../testing/datepicker-input-harness.spec.ts | 45 +++++++++++++------ .../form-field-control-harness-filters.ts | 18 ++++++++ .../control/form-field-control-harness.ts | 28 +++++++++++- .../form-field/testing/control/index.ts | 1 + .../input/testing/input-harness-filters.ts | 4 +- .../input/testing/input-harness.spec.ts | 6 +++ src/material/input/testing/input-harness.ts | 12 ++++- .../testing/native-select-harness-filters.ts | 3 +- .../testing/native-select-harness.spec.ts | 8 ++++ .../input/testing/native-select-harness.ts | 13 +++++- .../select/testing/select-harness-filters.ts | 3 +- .../select/testing/select-harness.spec.ts | 6 +++ src/material/select/testing/select-harness.ts | 21 +++++---- 17 files changed, 204 insertions(+), 37 deletions(-) create mode 100644 src/material/form-field/testing/control/form-field-control-harness-filters.ts 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..5e2a1f8ef4aa 100644 --- a/src/material/input/testing/input-harness.spec.ts +++ b/src/material/input/testing/input-harness.spec.ts @@ -39,6 +39,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 +236,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