diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0e7fc85358c9..8a38c241d08b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -93,6 +93,7 @@ /src/material-experimental/mdc-card/** @mmalerba /src/material-experimental/mdc-checkbox/** @mmalerba /src/material-experimental/mdc-chips/** @mmalerba +/src/material-experimental/mdc-dialog/** @devversion /src/material-experimental/mdc-helpers/** @mmalerba /src/material-experimental/mdc-menu/** @crisbeto /src/material-experimental/mdc-progress-spinner/** @andrewseguin diff --git a/src/cdk-experimental/testing/testbed/testbed-harness-environment.ts b/src/cdk-experimental/testing/testbed/testbed-harness-environment.ts index dabe3693fe1e..502c7c88eea3 100644 --- a/src/cdk-experimental/testing/testbed/testbed-harness-environment.ts +++ b/src/cdk-experimental/testing/testbed/testbed-harness-environment.ts @@ -14,7 +14,7 @@ import {UnitTestElement} from './unit-test-element'; /** A `HarnessEnvironment` implementation for Angular's Testbed. */ export class TestbedHarnessEnvironment extends HarnessEnvironment { - protected constructor(rawRootElement: Element, private _fixture: ComponentFixture) { + constructor(rawRootElement: Element, private _fixture: ComponentFixture) { super(rawRootElement); } diff --git a/src/material-experimental/mdc-dialog/harness/BUILD.bazel b/src/material-experimental/mdc-dialog/harness/BUILD.bazel new file mode 100644 index 000000000000..5e22d419fbbb --- /dev/null +++ b/src/material-experimental/mdc-dialog/harness/BUILD.bazel @@ -0,0 +1,32 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library") + +ts_library( + name = "harness", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/testing", + "//src/material/dialog", + ], +) + +ng_test_library( + name = "harness_tests", + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":harness", + "//src/cdk-experimental/testing", + "//src/cdk-experimental/testing/testbed", + "//src/material/dialog", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "tests", + deps = [":harness_tests"], +) diff --git a/src/material-experimental/mdc-dialog/harness/dialog-harness-filters.ts b/src/material-experimental/mdc-dialog/harness/dialog-harness-filters.ts new file mode 100644 index 000000000000..e07eeba64c62 --- /dev/null +++ b/src/material-experimental/mdc-dialog/harness/dialog-harness-filters.ts @@ -0,0 +1,11 @@ +/** + * @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 + */ + +export type DialogHarnessFilters = { + id?: string; +}; diff --git a/src/material-experimental/mdc-dialog/harness/dialog-harness.spec.ts b/src/material-experimental/mdc-dialog/harness/dialog-harness.spec.ts new file mode 100644 index 000000000000..9c2302610be9 --- /dev/null +++ b/src/material-experimental/mdc-dialog/harness/dialog-harness.spec.ts @@ -0,0 +1,132 @@ +import {HarnessLoader} from '@angular/cdk-experimental/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk-experimental/testing/testbed'; +import {Component, TemplateRef, ViewChild} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatDialog, MatDialogConfig, MatDialogModule} from '@angular/material/dialog'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MatDialogHarness} from './dialog-harness'; + +let fixture: ComponentFixture; +let loader: HarnessLoader; +let dialogHarness: typeof MatDialogHarness; + +describe('MatDialogHarness', () => { + describe('non-MDC-based', () => { + beforeEach(async () => { + await TestBed + .configureTestingModule({ + imports: [MatDialogModule, NoopAnimationsModule], + declarations: [DialogHarnessTest], + }) + .compileComponents(); + + fixture = TestBed.createComponent(DialogHarnessTest); + fixture.detectChanges(); + loader = new TestbedHarnessEnvironment(document.body, fixture); + dialogHarness = MatDialogHarness; + }); + + runTests(); + }); + + describe( + 'MDC-based', + () => { + // TODO: run tests for MDC based radio-button once implemented. + }); +}); + +/** Shared tests to run on both the original and MDC-based radio-button's. */ +function runTests() { + it('should load harness for dialog', async () => { + fixture.componentInstance.open(); + const dialogs = await loader.getAllHarnesses(dialogHarness); + expect(dialogs.length).toBe(1); + }); + + it('should load harness for dialog with specific id', async () => { + fixture.componentInstance.open({id: 'my-dialog'}); + fixture.componentInstance.open({id: 'other'}); + let dialogs = await loader.getAllHarnesses(dialogHarness); + expect(dialogs.length).toBe(2); + + dialogs = await loader.getAllHarnesses(dialogHarness.with({id: 'my-dialog'})); + expect(dialogs.length).toBe(1); + }); + + it('should be able to get id of dialog', async () => { + fixture.componentInstance.open({id: 'my-dialog'}); + fixture.componentInstance.open({id: 'other'}); + const dialogs = await loader.getAllHarnesses(dialogHarness); + expect(await dialogs[0].getId()).toBe('my-dialog'); + expect(await dialogs[1].getId()).toBe('other'); + }); + + it('should be able to get role of dialog', async () => { + fixture.componentInstance.open({role: 'alertdialog'}); + fixture.componentInstance.open({role: 'dialog'}); + fixture.componentInstance.open({role: undefined}); + const dialogs = await loader.getAllHarnesses(dialogHarness); + expect(await dialogs[0].getRole()).toBe('alertdialog'); + expect(await dialogs[1].getRole()).toBe('dialog'); + expect(await dialogs[2].getRole()).toBe(null); + }); + + it('should be able to get aria-label of dialog', async () => { + fixture.componentInstance.open(); + fixture.componentInstance.open({ariaLabel: 'Confirm purchase.'}); + const dialogs = await loader.getAllHarnesses(dialogHarness); + expect(await dialogs[0].getAriaLabel()).toBe(null); + expect(await dialogs[1].getAriaLabel()).toBe('Confirm purchase.'); + }); + + it('should be able to get aria-labelledby of dialog', async () => { + fixture.componentInstance.open(); + fixture.componentInstance.open({ariaLabelledBy: 'dialog-label'}); + const dialogs = await loader.getAllHarnesses(dialogHarness); + expect(await dialogs[0].getAriaLabelledby()).toBe(null); + expect(await dialogs[1].getAriaLabelledby()).toBe('dialog-label'); + }); + + it('should be able to get aria-describedby of dialog', async () => { + fixture.componentInstance.open(); + fixture.componentInstance.open({ariaDescribedBy: 'dialog-description'}); + const dialogs = await loader.getAllHarnesses(dialogHarness); + expect(await dialogs[0].getAriaDescribedby()).toBe(null); + expect(await dialogs[1].getAriaDescribedby()).toBe('dialog-description'); + }); + + it('should be able to close dialog', async () => { + fixture.componentInstance.open({disableClose: true}); + fixture.componentInstance.open(); + let dialogs = await loader.getAllHarnesses(dialogHarness); + + expect(dialogs.length).toBe(2); + await dialogs[0].close(); + + dialogs = await loader.getAllHarnesses(dialogHarness); + expect(dialogs.length).toBe(1); + + // should be a noop since "disableClose" is set to "true". + await dialogs[0].close(); + dialogs = await loader.getAllHarnesses(dialogHarness); + expect(dialogs.length).toBe(1); + }); +} + +@Component({ + template: ` + + Hello from the dialog! + + ` +}) +class DialogHarnessTest { + @ViewChild(TemplateRef, {static: false}) dialogTmpl: TemplateRef; + + constructor(readonly dialog: MatDialog) {} + + open(config?: MatDialogConfig) { + return this.dialog.open(this.dialogTmpl, config); + } +} diff --git a/src/material-experimental/mdc-dialog/harness/dialog-harness.ts b/src/material-experimental/mdc-dialog/harness/dialog-harness.ts new file mode 100644 index 000000000000..c5521f003a8a --- /dev/null +++ b/src/material-experimental/mdc-dialog/harness/dialog-harness.ts @@ -0,0 +1,69 @@ +/** + * @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-experimental/testing'; +import {DialogRole} from '@angular/material/dialog'; +import {DialogHarnessFilters} from './dialog-harness-filters'; + +/** + * Harness for interacting with a standard MatDialog in tests. + * @dynamic + */ +export class MatDialogHarness extends ComponentHarness { + // Developers can provide a custom component or template for the + // dialog. The canonical dialog parent is the "MatDialogContainer". + static hostSelector = '.mat-dialog-container'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a dialog with + * specific attributes. + * @param options Options for narrowing the search: + * - `id` finds a dialog with specific id. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: DialogHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatDialogHarness) + .addOption('id', options.id, async (harness, id) => (await harness.getId()) === id); + } + + /** Gets the id of the dialog. */ + async getId(): Promise { + const id = await (await this.host()).getAttribute('id'); + // In case no id has been specified, the "id" property always returns + // an empty string. To make this method more explicit, we return null. + return id !== '' ? id : null; + } + + /** Gets the role of the dialog. */ + async getRole(): Promise { + return (await this.host()).getAttribute('role') as Promise; + } + + /** Gets the value of the dialog's "aria-label" attribute. */ + async getAriaLabel(): Promise { + return (await this.host()).getAttribute('aria-label'); + } + + /** Gets the value of the dialog's "aria-labelledby" attribute. */ + async getAriaLabelledby(): Promise { + return (await this.host()).getAttribute('aria-labelledby'); + } + + /** Gets the value of the dialog's "aria-describedby" attribute. */ + async getAriaDescribedby(): Promise { + return (await this.host()).getAttribute('aria-describedby'); + } + + /** + * Closes the dialog by pressing escape. Note that this method cannot + * be used if "disableClose" has been set to true for the dialog. + */ + async close(): Promise { + await (await this.host()).sendKeys(TestKey.ESCAPE); + } +}