Skip to content

feat(chips): add test harness #20028

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

Merged
merged 1 commit into from
Jul 28, 2020
Merged
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
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@
/tools/public_api_guard/material/card.d.ts @jelbourn
/tools/public_api_guard/material/checkbox.d.ts @jelbourn @devversion
/tools/public_api_guard/material/chips.d.ts @jelbourn
/tools/public_api_guard/material/chips/testing.d.ts @jelbourn
/tools/public_api_guard/material/core.d.ts @jelbourn
/tools/public_api_guard/material/datepicker.d.ts @mmalerba
/tools/public_api_guard/material/dialog.d.ts @jelbourn @crisbeto
Expand Down
4 changes: 4 additions & 0 deletions src/material/chips/chip-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,10 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
/** Associates an HTML input element with this chip list. */
registerInput(inputElement: MatChipTextControl): void {
this._chipInput = inputElement;

// We use this attribute to match the chip list to its input in test harnesses.
// Set the attribute directly here to avoid "changed after checked" errors.
this._elementRef.nativeElement.setAttribute('data-mat-chip-input', inputElement.id);
}

/**
Expand Down
52 changes: 52 additions & 0 deletions src/material/chips/testing/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library")

package(default_visibility = ["//visibility:public"])

ts_library(
name = "testing",
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
module_name = "@angular/material/chips/testing",
deps = [
"//src/cdk/testing",
],
)

filegroup(
name = "source-files",
srcs = glob(["**/*.ts"]),
)

ng_test_library(
name = "harness_tests_lib",
srcs = ["shared.spec.ts"],
deps = [
":testing",
"//src/cdk/testing",
"//src/cdk/testing/private",
"//src/cdk/testing/testbed",
"//src/material/chips",
"//src/material/form-field",
"@npm//@angular/platform-browser",
],
)

ng_test_library(
name = "unit_tests_lib",
srcs = glob(
["**/*.spec.ts"],
exclude = ["shared.spec.ts"],
),
deps = [
":harness_tests_lib",
":testing",
"//src/material/chips",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_tests_lib"],
)
30 changes: 30 additions & 0 deletions src/material/chips/testing/chip-harness-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @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.io/license
*/
import {BaseHarnessFilters} from '@angular/cdk/testing';

/** A set of criteria that can be used to filter a list of `MatChipHarness` instances. */
export interface ChipHarnessFilters extends BaseHarnessFilters {
/** Only find instances whose text matches the given value. */
text?: string | RegExp;
/** Only find chip instances whose selected state matches the given value. */
selected?: boolean;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mdc versions of chips has types of chips that don't have a selected state- this would affect the filter as well as the chip harness API. Would it make sense to author the harness for the existing chip-list against the concepts of the MDC-based chips so that we're going in the direction we want? (e.g MatChipListbox, MatChipOption, etc.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't those just always return false? I think that no matter what, we'll have some differences in the harnesses between the current ones and MDC, because the public API is different.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC the MDC-based mat-chip-listbox should be equivalent to the current mat-chip-list, so maybe the end result should look something like

MatChipHarness - any kind of chip, no selection API
MatChipSetHarness - any kind of chip container, no selection API

MatChipOptionHarness - specifically a listbox (option) chip, extends MatChipHarness, selection API
MatChipListboxHarness - specifically listbox chip container, extends MatChipSetHarness, selection API
MatChipListHarness becomes an alias for MatChipListboxHarness

Thoughts?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO that makes sense for the MDC chips, but not for the existing ones. Somebody that hasn't used the MDC chips won't know what a "chip listbox" is referring to. I don't think it's worth it to avoid breaking changes in the harnesses, considering that the component's public API has breaking changes already.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think since one of the main goals for the harnesses is to help with the MDC migration, we should align the API with the current version of chips for now. Once the migration is done we may want to change it to align better with the new API of the MDC version.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would this all work with the current version of chips though? Would there would be 2 different harnesses for MatChip (MatChipHarness, MatChipOptionHarness) and users just pick the ones that gives the functionality they want in their test?

Copy link
Member Author

@crisbeto crisbeto Jul 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's worth noting that selectable is an input so a chip could change type between change detection runs. That means that people might have to use two harnesses for the same element.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could just use MatOptionHarness exclusively and it would report false if it wasn't selectable, so I don't think you'd need to use both for a single element (unless MatChipHarness does something that MatChipOptionHarness doesn't)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the point I was making above about always using MatChipOption. Anyway, I don't think that we can get away with using the exact same set of harnesses for both MDC and the current version, because they're fundamentally different APIs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose I had just been imagining that we could just completely omit the selection APIs from MatChipHarness for now, and only add them to the mdc ones.

I admit it's not ideal. Thinking about it more, I can live with going with this API for now and just committing to writing migration schematics for chips specifically because the APIs should be pretty easy to transform.

}

/** A set of criteria that can be used to filter a list of `MatChipListHarness` instances. */
export interface ChipListHarnessFilters extends BaseHarnessFilters {}

/** A set of criteria that can be used to filter a list of `MatChipListInputHarness` instances. */
export interface ChipInputHarnessFilters extends BaseHarnessFilters {
/** Filters based on the value of the input. */
value?: string | RegExp;
/** Filters based on the placeholder text of the input. */
placeholder?: string | RegExp;
}

/** A set of criteria that can be used to filter a list of `MatChipRemoveHarness` instances. */
export interface ChipRemoveHarnessFilters extends BaseHarnessFilters {}
78 changes: 78 additions & 0 deletions src/material/chips/testing/chip-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* @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.io/license
*/

import {ComponentHarness, HarnessPredicate, TestKey} from '@angular/cdk/testing';
import {ChipHarnessFilters, ChipRemoveHarnessFilters} from './chip-harness-filters';
import {MatChipRemoveHarness} from './chip-remove-harness';

/** Harness for interacting with a standard Angular Material chip in tests. */
export class MatChipHarness extends ComponentHarness {
/** The selector for the host element of a `MatChip` instance. */
static hostSelector = '.mat-chip';

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatChipHarness` that meets
* certain criteria.
* @param options Options for filtering which chip instances are considered a match.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: ChipHarnessFilters = {}): HarnessPredicate<MatChipHarness> {
return new HarnessPredicate(MatChipHarness, options)
.addOption('text', options.text,
(harness, label) => HarnessPredicate.stringMatches(harness.getText(), label))
.addOption('selected', options.selected,
async (harness, selected) => (await harness.isSelected()) === selected);
}

/** Gets the text of the chip. */
async getText(): Promise<string> {
return (await this.host()).text();
}

/** Whether the chip is selected. */
async isSelected(): Promise<boolean> {
return (await this.host()).hasClass('mat-chip-selected');
}

/** Whether the chip is disabled. */
async isDisabled(): Promise<boolean> {
return (await this.host()).hasClass('mat-chip-disabled');
}

/** Selects the given chip. Only applies if it's selectable. */
async select(): Promise<void> {
if (!(await this.isSelected())) {
await this.toggle();
}
}

/** Deselects the given chip. Only applies if it's selectable. */
async deselect(): Promise<void> {
if (await this.isSelected()) {
await this.toggle();
}
}

/** Toggles the selected state of the given chip. Only applies if it's selectable. */
async toggle(): Promise<void> {
return (await this.host()).sendKeys(' ');
}

/** Removes the given chip. Only applies if it's removable. */
async remove(): Promise<void> {
await (await this.host()).sendKeys(TestKey.DELETE);
}

/**
* Gets the remove button inside of a chip.
* @param filter Optionally filters which chips are included.
*/
async getRemoveButton(filter: ChipRemoveHarnessFilters = {}): Promise<MatChipRemoveHarness> {
return this.locatorFor(MatChipRemoveHarness.with(filter))();
}
}
95 changes: 95 additions & 0 deletions src/material/chips/testing/chip-input-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* @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.io/license
*/

import {HarnessPredicate, ComponentHarness, TestKey} from '@angular/cdk/testing';
import {ChipInputHarnessFilters} from './chip-harness-filters';

/** Harness for interacting with a standard Material chip inputs in tests. */
export class MatChipInputHarness extends ComponentHarness {
static hostSelector = '.mat-chip-input';

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatChipInputHarness` that meets
* certain criteria.
* @param options Options for filtering which input instances are considered a match.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: ChipInputHarnessFilters = {}): HarnessPredicate<MatChipInputHarness> {
return new HarnessPredicate(MatChipInputHarness, options)
.addOption('value', options.value, async (harness, value) => {
return (await harness.getValue()) === value;
})
.addOption('placeholder', options.placeholder, async (harness, placeholder) => {
return (await harness.getPlaceholder()) === placeholder;
});
}

/** Whether the input is disabled. */
async isDisabled(): Promise<boolean> {
return (await this.host()).getProperty('disabled')!;
}

/** Whether the input is required. */
async isRequired(): Promise<boolean> {
return (await this.host()).getProperty('required')!;
}

/** Gets the value of the input. */
async getValue(): Promise<string> {
// The "value" property of the native input is never undefined.
return (await (await this.host()).getProperty('value'))!;
}

/** Gets the placeholder of the input. */
async getPlaceholder(): Promise<string> {
return (await (await this.host()).getProperty('placeholder'));
}

/**
* Focuses the input and returns a promise that indicates when the
* action is complete.
*/
async focus(): Promise<void> {
return (await this.host()).focus();
}

/**
* Blurs the input and returns a promise that indicates when the
* action is complete.
*/
async blur(): Promise<void> {
return (await this.host()).blur();
}

/** Whether the input is focused. */
async isFocused(): Promise<boolean> {
return (await this.host()).isFocused();
}

/**
* Sets the value of the input. The value will be set by simulating
* keypresses that correspond to the given value.
*/
async setValue(newValue: string): Promise<void> {
const inputEl = await this.host();
await inputEl.clear();

// We don't want to send keys for the value if the value is an empty
// string in order to clear the value. Sending keys with an empty string
// still results in unnecessary focus events.
if (newValue) {
await inputEl.sendKeys(newValue);
}
}

/** Sends a chip separator key to the input element. */
async sendSeparatorKey(key: TestKey | string): Promise<void> {
const inputEl = await this.host();
return inputEl.sendKeys(key);
}
}
11 changes: 11 additions & 0 deletions src/material/chips/testing/chip-list-harness.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {MatChipsModule} from '@angular/material/chips';
import {runHarnessTests} from '@angular/material/chips/testing/shared.spec';
import {MatChipListHarness} from './chip-list-harness';
import {MatChipHarness} from './chip-harness';
import {MatChipInputHarness} from './chip-input-harness';
import {MatChipRemoveHarness} from './chip-remove-harness';

describe('Non-MDC-based MatChipListHarness', () => {
runHarnessTests(MatChipsModule, MatChipListHarness, MatChipHarness, MatChipInputHarness,
MatChipRemoveHarness);
});
95 changes: 95 additions & 0 deletions src/material/chips/testing/chip-list-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* @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.io/license
*/

import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
import {MatChipHarness} from './chip-harness';
import {MatChipInputHarness} from './chip-input-harness';
import {
ChipListHarnessFilters,
ChipHarnessFilters,
ChipInputHarnessFilters,
} from './chip-harness-filters';

/** Harness for interacting with a standard chip list in tests. */
export class MatChipListHarness extends ComponentHarness {
/** The selector for the host element of a `MatChipList` instance. */
static hostSelector = '.mat-chip-list';

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatChipListHarness` that meets
* certain criteria.
* @param options Options for filtering which chip list instances are considered a match.
* @return a `HarnessPredicate` configured with the given options.
*/
static with(options: ChipListHarnessFilters = {}): HarnessPredicate<MatChipListHarness> {
return new HarnessPredicate(MatChipListHarness, options);
}

/** Gets whether the chip list is disabled. */
async isDisabled(): Promise<boolean> {
return await (await this.host()).getAttribute('aria-disabled') === 'true';
}

/** Gets whether the chip list is required. */
async isRequired(): Promise<boolean> {
return await (await this.host()).getAttribute('aria-required') === 'true';
}

/** Gets whether the chip list is invalid. */
async isInvalid(): Promise<boolean> {
return await (await this.host()).getAttribute('aria-invalid') === 'true';
}

/** Gets whether the chip list is in multi selection mode. */
async isMultiple(): Promise<boolean> {
return await (await this.host()).getAttribute('aria-multiselectable') === 'true';
}

/** Gets whether the orientation of the chip list. */
async getOrientation(): Promise<'horizontal' | 'vertical'> {
const orientation = await (await this.host()).getAttribute('aria-orientation');
return orientation === 'vertical' ? 'vertical' : 'horizontal';
}

/**
* Gets the list of chips inside the chip list.
* @param filter Optionally filters which chips are included.
*/
async getChips(filter: ChipHarnessFilters = {}): Promise<MatChipHarness[]> {
return this.locatorForAll(MatChipHarness.with(filter))();
}

/**
* Selects a chip inside the chip list.
* @param filter An optional filter to apply to the child chips.
* All the chips matching the filter will be selected.
*/
async selectChips(filter: ChipHarnessFilters = {}): Promise<void> {
const chips = await this.getChips(filter);
if (!chips.length) {
throw Error(`Cannot find mat-chip matching filter ${JSON.stringify(filter)}`);
}
await Promise.all(chips.map(chip => chip.select()));
}

/**
* Gets the `MatChipInput` inside the chip list.
* @param filter Optionally filters which chip input is included.
*/
async getInput(filter: ChipInputHarnessFilters = {}): Promise<MatChipInputHarness> {
// The input isn't required to be a descendant of the chip list so we have to look it up by id.
const inputId = await (await this.host()).getAttribute('data-mat-chip-input');

if (!inputId) {
throw Error(`Chip list is not associated with an input`);
}

return this.documentRootLocatorFactory().locatorFor(
MatChipInputHarness.with({...filter, selector: `#${inputId}`}))();
}
}
Loading