diff --git a/src/cdk/private/testing/BUILD.bazel b/src/cdk/private/testing/BUILD.bazel index 57095d4d9221..54e09f83e05e 100644 --- a/src/cdk/private/testing/BUILD.bazel +++ b/src/cdk/private/testing/BUILD.bazel @@ -1,15 +1,12 @@ package(default_visibility = ["//visibility:public"]) -load("//tools:defaults.bzl", "ts_library") +load("//tools:defaults.bzl", "ng_test_library") -ts_library( +ng_test_library( name = "testing", srcs = glob( ["**/*.ts"], exclude = ["**/*.spec.ts"], ), module_name = "@angular/cdk/private/testing", - deps = [ - "@npm//@angular/core", - ], ) diff --git a/src/cdk/private/testing/expect-async-error.ts b/src/cdk/private/testing/expect-async-error.ts new file mode 100644 index 000000000000..1a6dde41e608 --- /dev/null +++ b/src/cdk/private/testing/expect-async-error.ts @@ -0,0 +1,22 @@ +/** + * @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 + */ + +/** + * Expects the asynchronous function to throw an error that matches + * the specified expectation. + */ +export async function expectAsyncError(fn: () => Promise, expectation: RegExp) { + let error: string|null = null; + try { + await fn(); + } catch (e) { + error = e.toString(); + } + expect(error).not.toBe(null); + expect(error!).toMatch(expectation, 'Expected error to be thrown.'); +} diff --git a/src/cdk/private/testing/public-api.ts b/src/cdk/private/testing/public-api.ts index b0e9f7f02cc8..b41c0604195a 100644 --- a/src/cdk/private/testing/public-api.ts +++ b/src/cdk/private/testing/public-api.ts @@ -6,5 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ +export * from './expect-async-error'; export * from './wrapped-error-message'; export * from './mock-ng-zone'; diff --git a/src/material/snack-bar/testing/BUILD.bazel b/src/material/snack-bar/testing/BUILD.bazel new file mode 100644 index 000000000000..acde8e05a375 --- /dev/null +++ b/src/material/snack-bar/testing/BUILD.bazel @@ -0,0 +1,47 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite") + +ng_module( + name = "testing", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + module_name = "@angular/material/snack-bar/testing", + deps = [ + "//src/cdk/testing", + ], +) + +ng_test_library( + name = "harness_tests_lib", + srcs = ["shared.spec.ts"], + deps = [ + ":testing", + "//src/cdk/overlay", + "//src/cdk/private/testing", + "//src/cdk/testing", + "//src/cdk/testing/testbed", + "//src/material/snack-bar", + "@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/snack-bar", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_tests_lib"], +) diff --git a/src/material/snack-bar/testing/index.ts b/src/material/snack-bar/testing/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material/snack-bar/testing/index.ts @@ -0,0 +1,9 @@ +/** + * @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 * from './public-api'; diff --git a/src/material/snack-bar/testing/public-api.ts b/src/material/snack-bar/testing/public-api.ts new file mode 100644 index 000000000000..0c6bbfb1e726 --- /dev/null +++ b/src/material/snack-bar/testing/public-api.ts @@ -0,0 +1,10 @@ +/** + * @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 * from './snack-bar-harness'; +export * from './snack-bar-harness-filters'; diff --git a/src/material/snack-bar/testing/shared.spec.ts b/src/material/snack-bar/testing/shared.spec.ts new file mode 100644 index 000000000000..8ff9eb25c84b --- /dev/null +++ b/src/material/snack-bar/testing/shared.spec.ts @@ -0,0 +1,160 @@ +import {OverlayContainer} from '@angular/cdk/overlay'; +import {expectAsyncError} from '@angular/cdk/private/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {Component, TemplateRef, ViewChild} from '@angular/core'; +import {ComponentFixture, inject, TestBed} from '@angular/core/testing'; +import {MatSnackBar, MatSnackBarConfig, MatSnackBarModule} from '@angular/material/snack-bar'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MatSnackBarHarness} from './snack-bar-harness'; + +/** + * Function that can be used to run the shared snack-bar harness tests for either + * the non-MDC or MDC based snack-bar harness. + */ +export function runHarnessTests( + snackBarModule: typeof MatSnackBarModule, + snackBarHarness: typeof MatSnackBarHarness) { + let fixture: ComponentFixture; + let loader: HarnessLoader; + let overlayContainer: OverlayContainer; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [snackBarModule, NoopAnimationsModule], + declarations: [SnackbarHarnessTest], + }).compileComponents(); + + fixture = TestBed.createComponent(SnackbarHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + inject([OverlayContainer], (oc: OverlayContainer) => { + overlayContainer = oc; + })(); + }); + + afterEach(() => { + // Angular won't call this for us so we need to do it ourselves to avoid leaks. + overlayContainer.ngOnDestroy(); + overlayContainer = null!; + }); + + it('should load harness for simple snack-bar', async () => { + const snackBarRef = fixture.componentInstance.openSimple('Hello!', ''); + let snackBars = await loader.getAllHarnesses(snackBarHarness); + + expect(snackBars.length).toBe(1); + + snackBarRef.dismiss(); + snackBars = await loader.getAllHarnesses(snackBarHarness); + expect(snackBars.length).toBe(0); + }); + + it('should load harness for custom snack-bar', async () => { + const snackBarRef = fixture.componentInstance.openCustom(); + let snackBars = await loader.getAllHarnesses(snackBarHarness); + + expect(snackBars.length).toBe(1); + + snackBarRef.dismiss(); + snackBars = await loader.getAllHarnesses(snackBarHarness); + expect(snackBars.length).toBe(0); + }); + + it('should load snack-bar harness by selector', async () => { + fixture.componentInstance.openSimple('Hello!', '', {panelClass: 'my-snack-bar'}); + const snackBars = await loader.getAllHarnesses(snackBarHarness.with({ + selector: '.my-snack-bar' + })); + expect(snackBars.length).toBe(1); + }); + + it('should be able to get role of snack-bar', async () => { + fixture.componentInstance.openCustom(); + let snackBar = await loader.getHarness(snackBarHarness); + expect(await snackBar.getRole()).toBe('alert'); + + fixture.componentInstance.openCustom({politeness: 'polite'}); + snackBar = await loader.getHarness(snackBarHarness); + expect(await snackBar.getRole()).toBe('status'); + + fixture.componentInstance.openCustom({politeness: 'off'}); + snackBar = await loader.getHarness(snackBarHarness); + expect(await snackBar.getRole()).toBe(null); + }); + + it('should be able to get message of simple snack-bar', async () => { + fixture.componentInstance.openSimple('Subscribed to newsletter.'); + let snackBar = await loader.getHarness(snackBarHarness); + expect(await snackBar.getMessage()).toBe('Subscribed to newsletter.'); + + // For snack-bar's with custom template, the message cannot be + // retrieved. We expect an error to be thrown. + fixture.componentInstance.openCustom(); + snackBar = await loader.getHarness(snackBarHarness); + await expectAsyncError(() => snackBar.getMessage(), /custom content/); + }); + + it('should be able to get action description of simple snack-bar', async () => { + fixture.componentInstance.openSimple('Hello', 'Unsubscribe'); + let snackBar = await loader.getHarness(snackBarHarness); + expect(await snackBar.getActionDescription()).toBe('Unsubscribe'); + + // For snack-bar's with custom template, the action description + // cannot be retrieved. We expect an error to be thrown. + fixture.componentInstance.openCustom(); + snackBar = await loader.getHarness(snackBarHarness); + await expectAsyncError(() => snackBar.getActionDescription(), /custom content/); + }); + + it('should be able to check whether simple snack-bar has action', async () => { + fixture.componentInstance.openSimple('With action', 'Unsubscribe'); + let snackBar = await loader.getHarness(snackBarHarness); + expect(await snackBar.hasAction()).toBe(true); + + fixture.componentInstance.openSimple('No action'); + snackBar = await loader.getHarness(snackBarHarness); + expect(await snackBar.hasAction()).toBe(false); + + // For snack-bar's with custom template, the action cannot + // be found. We expect an error to be thrown. + fixture.componentInstance.openCustom(); + snackBar = await loader.getHarness(snackBarHarness); + await expectAsyncError(() => snackBar.hasAction(), /custom content/); + }); + + it('should be able to dismiss simple snack-bar with action', async () => { + const snackBarRef = fixture.componentInstance.openSimple('With action', 'Unsubscribe'); + let snackBar = await loader.getHarness(snackBarHarness); + let actionCount = 0; + snackBarRef.onAction().subscribe(() => actionCount++); + + await snackBar.dismissWithAction(); + expect(actionCount).toBe(1); + + fixture.componentInstance.openSimple('No action'); + snackBar = await loader.getHarness(snackBarHarness); + await expectAsyncError(() => snackBar.dismissWithAction(), /without action/); + }); +} + +@Component({ + template: ` + + My custom snack-bar. + + ` +}) +class SnackbarHarnessTest { + @ViewChild(TemplateRef, {static: false}) customTmpl: TemplateRef; + + constructor(readonly snackBar: MatSnackBar) {} + + openSimple(message: string, action = '', config?: MatSnackBarConfig) { + return this.snackBar.open(message, action, config); + } + + openCustom(config?: MatSnackBarConfig) { + return this.snackBar.openFromTemplate(this.customTmpl, config); + } +} diff --git a/src/material/snack-bar/testing/snack-bar-harness-filters.ts b/src/material/snack-bar/testing/snack-bar-harness-filters.ts new file mode 100644 index 000000000000..51654e1b665a --- /dev/null +++ b/src/material/snack-bar/testing/snack-bar-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 + */ + +import {BaseHarnessFilters} from '@angular/cdk/testing'; + +export interface SnackBarHarnessFilters extends BaseHarnessFilters {} diff --git a/src/material/snack-bar/testing/snack-bar-harness.spec.ts b/src/material/snack-bar/testing/snack-bar-harness.spec.ts new file mode 100644 index 000000000000..393419f37f2d --- /dev/null +++ b/src/material/snack-bar/testing/snack-bar-harness.spec.ts @@ -0,0 +1,7 @@ +import {MatSnackBarModule} from '@angular/material/snack-bar'; +import {runHarnessTests} from '@angular/material/snack-bar/testing/shared.spec'; +import {MatSnackBarHarness} from './snack-bar-harness'; + +describe('Non-MDC-based MatSnackBarHarness', () => { + runHarnessTests(MatSnackBarModule, MatSnackBarHarness); +}); diff --git a/src/material/snack-bar/testing/snack-bar-harness.ts b/src/material/snack-bar/testing/snack-bar-harness.ts new file mode 100644 index 000000000000..51357b706921 --- /dev/null +++ b/src/material/snack-bar/testing/snack-bar-harness.ts @@ -0,0 +1,108 @@ +/** + * @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 {SnackBarHarnessFilters} from './snack-bar-harness-filters'; + +/** + * Harness for interacting with a standard mat-snack-bar in tests. + * @dynamic + */ +export class MatSnackBarHarness extends ComponentHarness { + // Developers can provide a custom component or template for the + // snackbar. The canonical snack-bar parent is the "MatSnackBarContainer". + static hostSelector = '.mat-snack-bar-container'; + + private _simpleSnackBar = this.locatorForOptional('.mat-simple-snackbar'); + private _simpleSnackBarMessage = this.locatorFor('.mat-simple-snackbar > span'); + private _simpleSnackBarActionButton = + this.locatorForOptional('.mat-simple-snackbar-action > button'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a snack-bar with + * specific attributes. + * @param options Options for narrowing the search. + * - `selector` finds a snack-bar that matches the given selector. Note that the + * selector must match the snack-bar container element. + * @return `HarnessPredicate` configured with the given options. + */ + static with(options: SnackBarHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatSnackBarHarness, options); + } + + /** + * Gets the role of the snack-bar. The role of a snack-bar is determined based + * on the ARIA politeness specified in the snack-bar config. + */ + async getRole(): Promise<'alert'|'status'|null> { + return (await this.host()).getAttribute('role') as Promise<'alert'|'status'|null>; + } + + /** + * Gets whether the snack-bar has an action. Method cannot be + * used for snack-bar's with custom content. + */ + async hasAction(): Promise { + await this._assertSimpleSnackBar(); + return (await this._simpleSnackBarActionButton()) !== null; + } + + /** + * Gets the description of the snack-bar. Method cannot be + * used for snack-bar's without action or with custom content. + */ + async getActionDescription(): Promise { + await this._assertSimpleSnackBarWithAction(); + return (await this._simpleSnackBarActionButton())!.text(); + } + + + /** + * Dismisses the snack-bar by clicking the action button. Method cannot + * be used for snack-bar's without action or with custom content. + */ + async dismissWithAction(): Promise { + await this._assertSimpleSnackBarWithAction(); + await (await this._simpleSnackBarActionButton())!.click(); + } + + /** + * Gets the message of the snack-bar. Method cannot be used for + * snack-bar's with custom content. + */ + async getMessage(): Promise { + await this._assertSimpleSnackBar(); + return (await this._simpleSnackBarMessage()).text(); + } + + /** + * Asserts that the current snack-bar does not use custom content. Throws if + * custom content is used. + */ + private async _assertSimpleSnackBar(): Promise { + if (!await this._isSimpleSnackBar()) { + throw new Error('Method cannot be used for snack-bar with custom content.'); + } + } + + /** + * Asserts that the current snack-bar does not use custom content and has + * an action defined. Otherwise an error will be thrown. + */ + private async _assertSimpleSnackBarWithAction(): Promise { + await this._assertSimpleSnackBar(); + if (!await this.hasAction()) { + throw new Error('Method cannot be used for standard snack-bar without action.'); + } + } + + /** Gets whether the snack-bar is using the default content template. */ + private async _isSimpleSnackBar(): Promise { + return await this._simpleSnackBar() !== null; + } +} diff --git a/test/karma-system-config.js b/test/karma-system-config.js index 60b9870d10cf..17c65c273229 100644 --- a/test/karma-system-config.js +++ b/test/karma-system-config.js @@ -139,6 +139,8 @@ System.config({ '@angular/material/slide-toggle': 'dist/packages/material/slide-toggle/index.js', '@angular/material/slider': 'dist/packages/material/slider/index.js', '@angular/material/snack-bar': 'dist/packages/material/snack-bar/index.js', + '@angular/material/snack-bar/testing': 'dist/packages/material/snack-bar/testing/index.js', + '@angular/material/snack-bar/testing/shared.spec': 'dist/packages/material/snack-bar/testing/shared.spec.js', '@angular/material/sort': 'dist/packages/material/sort/index.js', '@angular/material/stepper': 'dist/packages/material/stepper/index.js', '@angular/material/table': 'dist/packages/material/table/index.js',