Skip to content

feat(material/testing): Extend Angular harness testing functionality #30960

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion src/cdk/testing/component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ export interface HarnessLoader {
*/
getHarnessOrNull<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T | null>;

/**
* 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<T extends ComponentHarness>(query: HarnessQuery<T>, index: number): Promise<T>;

/**
* 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.
Expand All @@ -127,6 +138,14 @@ export interface HarnessLoader {
*/
getAllHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T[]>;

/**
* 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<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<number>;

/**
* 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.
Expand Down Expand Up @@ -500,6 +519,20 @@ export abstract class ContentContainerComponentHarness<S extends string = string
return (await this.getRootHarnessLoader()).getHarnessOrNull(query);
}

/**
* Gets a matching harness for the given query and index within the current harness's content.
* @param query The harness query to search for.
* @param index The zero-indexed offset of the component to find.
* @returns The first harness matching the given query.
* @throws If no matching harness is found.
*/
async getHarnessAtIndex<T extends ComponentHarness>(
query: HarnessQuery<T>,
index: number,
): Promise<T> {
return (await this.getRootHarnessLoader()).getHarnessAtIndex(query, index);
}

/**
* Gets all matching harnesses for the given query within the current harness's content.
* @param query The harness query to search for.
Expand All @@ -509,12 +542,23 @@ export abstract class ContentContainerComponentHarness<S extends string = string
return (await this.getRootHarnessLoader()).getAllHarnesses(query);
}

/**
* Returns the number of matching harnesses for the given query within the current harness's
* content.
*
* @param query The harness query to search for.
* @returns The number of matching harnesses for the given query.
*/
async countHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<number> {
return (await this.getRootHarnessLoader()).countHarnesses(query);
}

/**
* Checks whether there is a matching harnesses for the given query within the current harness's
* content.
*
* @param query The harness query to search for.
* @returns Whetehr there is matching harnesses for the given query.
* @returns Whether there is matching harnesses for the given query.
*/
async hasHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<boolean> {
return (await this.getRootHarnessLoader()).hasHarness(query);
Expand Down
34 changes: 34 additions & 0 deletions src/cdk/testing/harness-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,30 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
return this.locatorForOptional(query)();
}

/**
* Searches for an instance of the component corresponding to the given harness type and index
* under the `HarnessEnvironment`'s root element, and returns a `ComponentHarness` for that
* instance. The index specifies the offset of the component to find. If no matching
* component is found at that index, an error is thrown.
* @param query A query for a harness to create
* @param index The zero-indexed offset of the component to find
* @return An instance of the given harness type
* @throws If a matching component instance can't be found.
*/
async getHarnessAtIndex<T extends ComponentHarness>(
query: HarnessQuery<T>,
offset: number,
): Promise<T> {
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];
}

/**
* Searches for all instances of the component corresponding to the given harness type under the
* `HarnessEnvironment`'s root element, and returns a list `ComponentHarness` for each instance.
Expand All @@ -258,6 +282,16 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
return this.locatorForAll(query)();
}

/**
* Searches for all instance of the component corresponding to the given harness type under the
* `HarnessEnvironment`'s root element, and returns the number that were found.
* @param query A query for a harness to create
* @return The number of instances that were found.
*/
async countHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<number> {
return (await this.locatorForAll(query)()).length;
}

/**
* Searches for an instance of the component corresponding to the given harness type under the
* `HarnessEnvironment`'s root element, and returns a boolean indicating if any were found.
Expand Down
5 changes: 4 additions & 1 deletion src/cdk/testing/test-harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,12 @@ are used to create `ComponentHarness` instances for elements under this root ele
| `getChildLoader(selector: string): Promise<HarnessLoader>` | 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<HarnessLoader[]>` | Acts like `getChildLoader`, but returns an array of `HarnessLoader` instances, one for each matching element, rather than just the first matching element |
| `getHarness<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>): Promise<T>` | 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<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>, index: number): Promise<T>` | Acts like `getHarness`, but returns an instance of the harness corresponding to the matching element with the given index (zero-indexed) |
| `getAllHarnesses<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>): Promise<T[]>` | Acts like `getHarness`, but returns an array of harness instances, one for each matching element, rather than just the first matching element |
| `countHarnesses<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>): Promise<number>` | Counts the number of instances of the given `ComponentHarness` class or `HarnessPredicate` below the root element of this `HarnessLoader`, and returns the result. |
| `hasHarness<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>): Promise<boolean>` | 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
MatEndDate,
MatStartDate,
} from '../../datepicker';
import {MatFormFieldModule} from '../../form-field';
import {MatCalendarHarness} from './calendar-harness';
import {
MatDateRangeInputHarness,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -261,13 +269,22 @@ describe('matDateRangeInputHarness', () => {
<input matStartDate>
<input matEndDate>
</mat-date-range-input>

<mat-form-field>
<mat-label>Date range</mat-label>
<mat-date-range-input basic>
<input matStartDate>
<input matEndDate>
</mat-date-range-input>
</mat-form-field>
`,
imports: [
MatNativeDateModule,
MatDateRangeInput,
MatStartDate,
MatEndDate,
MatDateRangePicker,
MatFormFieldModule,
FormsModule,
],
})
Expand Down
39 changes: 34 additions & 5 deletions src/material/datepicker/testing/date-range-input-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -57,11 +59,13 @@ export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase {
static with(
options: DateRangeInputHarnessFilters = {},
): HarnessPredicate<MatDateRangeInputHarness> {
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. */
Expand All @@ -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<string | null> {
// Copied from MatFormFieldControlHarnessBase 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<string> {
return (await this.locatorFor('.mat-date-range-input-separator')()).text();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {ComponentHarnessConstructor, HarnessPredicate} from '@angular/cdk/testing';
import {MatFormFieldControlHarness} from '../../form-field/testing/control';
import {MatFormFieldControlHarnessBase} from '@angular/material/form-field/testing/control';
import {DatepickerInputHarnessFilters} from './datepicker-harness-filters';

/** Sets up the filter predicates for a datepicker input harness. */
Expand All @@ -21,11 +21,14 @@ export function getInputPredicate<T extends MatDatepickerInputHarnessBase>(
})
.addOption('placeholder', options.placeholder, (harness, placeholder) => {
return HarnessPredicate.stringMatches(harness.getPlaceholder(), placeholder);
})
.addOption('label', options.label, (harness, label) => {
return HarnessPredicate.stringMatches(harness.getLabel(), label);
});
}

/** Base class for datepicker input harnesses. */
export abstract class MatDatepickerInputHarnessBase extends MatFormFieldControlHarness {
export abstract class MatDatepickerInputHarnessBase extends MatFormFieldControlHarnessBase {
/** Whether the input is disabled. */
async isDisabled(): Promise<boolean> {
return (await this.host()).getProperty<boolean>('disabled');
Expand Down
45 changes: 32 additions & 13 deletions src/material/datepicker/testing/datepicker-input-harness.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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();
Expand Down Expand Up @@ -187,21 +196,31 @@ describe('MatDatepickerInputHarness', () => {

@Component({
template: `
<input
id="basic"
matInput
[matDatepicker]="picker"
(dateChange)="dateChangeCount = dateChangeCount + 1"
[(ngModel)]="date"
[min]="minDate"
[max]="maxDate"
[disabled]="disabled"
[required]="required"
placeholder="Type a date">
<mat-datepicker #picker [touchUi]="touchUi"></mat-datepicker>
<mat-form-field>
<mat-label>Pick a date</mat-label>
<input
id="basic"
matInput
[matDatepicker]="picker"
(dateChange)="dateChangeCount = dateChangeCount + 1"
[(ngModel)]="date"
[min]="minDate"
[max]="maxDate"
[disabled]="disabled"
[required]="required"
placeholder="Type a date">
<mat-datepicker #picker [touchUi]="touchUi"></mat-datepicker>
</mat-form-field>

<input id="no-datepicker" matDatepicker>
`,
imports: [MatNativeDateModule, MatDatepickerModule, FormsModule],
imports: [
MatNativeDateModule,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
],
})
class DatepickerInputHarnessTest {
date: Date | null = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Loading