From b60f4273ac9f2fdeac58d7cf23008a23db06634a Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Tue, 14 Apr 2026 08:01:34 +0000 Subject: [PATCH 1/2] feat(aria/tabs): add test harnesses --- goldens/aria/tabs/testing/index.api.md | 49 ++++++ src/aria/config.bzl | 1 + src/aria/tabs/testing/BUILD.bazel | 38 +++++ src/aria/tabs/testing/index.ts | 9 ++ src/aria/tabs/testing/public-api.ts | 10 ++ src/aria/tabs/testing/tabs-harness-filters.ts | 22 +++ src/aria/tabs/testing/tabs-harness.spec.ts | 143 ++++++++++++++++++ src/aria/tabs/testing/tabs-harness.ts | 104 +++++++++++++ 8 files changed, 376 insertions(+) create mode 100644 goldens/aria/tabs/testing/index.api.md create mode 100644 src/aria/tabs/testing/BUILD.bazel create mode 100644 src/aria/tabs/testing/index.ts create mode 100644 src/aria/tabs/testing/public-api.ts create mode 100644 src/aria/tabs/testing/tabs-harness-filters.ts create mode 100644 src/aria/tabs/testing/tabs-harness.spec.ts create mode 100644 src/aria/tabs/testing/tabs-harness.ts diff --git a/goldens/aria/tabs/testing/index.api.md b/goldens/aria/tabs/testing/index.api.md new file mode 100644 index 000000000000..e32a11390adf --- /dev/null +++ b/goldens/aria/tabs/testing/index.api.md @@ -0,0 +1,49 @@ +## API Report File for "@angular/aria_tabs_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseHarnessFilters } from '@angular/cdk/testing'; +import { ComponentHarness } from '@angular/cdk/testing'; +import { ContentContainerComponentHarness } from '@angular/cdk/testing'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { HarnessPredicate } from '@angular/cdk/testing'; + +// @public +export class TabHarness extends ContentContainerComponentHarness { + // (undocumented) + protected getRootHarnessLoader(): Promise; + getTitle(): Promise; + // (undocumented) + static hostSelector: string; + isActive(): Promise; + isDisabled(): Promise; + isSelected(): Promise; + select(): Promise; + static with(options?: TabHarnessFilters): HarnessPredicate; +} + +// @public +export interface TabHarnessFilters extends BaseHarnessFilters { + disabled?: boolean; + selected?: boolean; + title?: string | RegExp; +} + +// @public +export class TabsHarness extends ComponentHarness { + getSelectedTab(): Promise; + getTabs(filters?: TabHarnessFilters): Promise; + // (undocumented) + static hostSelector: string; + static with(options?: TabsHarnessFilters): HarnessPredicate; +} + +// @public +export interface TabsHarnessFilters extends BaseHarnessFilters { +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/src/aria/config.bzl b/src/aria/config.bzl index fe6d38a290fc..adba36ba70e9 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -9,6 +9,7 @@ ARIA_ENTRYPOINTS = [ "menu", "menu/testing", "tabs", + "tabs/testing", "toolbar", "toolbar/testing", "tree", diff --git a/src/aria/tabs/testing/BUILD.bazel b/src/aria/tabs/testing/BUILD.bazel new file mode 100644 index 000000000000..221855a1799f --- /dev/null +++ b/src/aria/tabs/testing/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "testing", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk/testing", + ], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) + +ng_project( + name = "unit_tests_lib", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":testing", + "//:node_modules/@angular/common", + "//:node_modules/@angular/core", + "//src/aria/tabs", + "//src/cdk/testing", + "//src/cdk/testing/testbed", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_tests_lib"], +) diff --git a/src/aria/tabs/testing/index.ts b/src/aria/tabs/testing/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/aria/tabs/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.dev/license + */ + +export * from './public-api'; diff --git a/src/aria/tabs/testing/public-api.ts b/src/aria/tabs/testing/public-api.ts new file mode 100644 index 000000000000..651c177c0555 --- /dev/null +++ b/src/aria/tabs/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.dev/license + */ + +export * from './tabs-harness'; +export * from './tabs-harness-filters'; diff --git a/src/aria/tabs/testing/tabs-harness-filters.ts b/src/aria/tabs/testing/tabs-harness-filters.ts new file mode 100644 index 000000000000..c257e4dda2c1 --- /dev/null +++ b/src/aria/tabs/testing/tabs-harness-filters.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.dev/license + */ + +import {BaseHarnessFilters} from '@angular/cdk/testing'; + +/** A set of criteria that can be used to filter a list of `TabsHarness` instances. */ +export interface TabsHarnessFilters extends BaseHarnessFilters {} + +/** A set of criteria that can be used to filter a list of `TabHarness` instances. */ +export interface TabHarnessFilters extends BaseHarnessFilters { + /** Only find instances whose title matches the given value. */ + title?: string | RegExp; + /** Only find instances that are selected. */ + selected?: boolean; + /** Only find instances that are disabled. */ + disabled?: boolean; +} diff --git a/src/aria/tabs/testing/tabs-harness.spec.ts b/src/aria/tabs/testing/tabs-harness.spec.ts new file mode 100644 index 000000000000..3d57c9be40dd --- /dev/null +++ b/src/aria/tabs/testing/tabs-harness.spec.ts @@ -0,0 +1,143 @@ +/** + * @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 {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ComponentHarness, HarnessLoader} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {Tabs, TabList, Tab, TabPanel, TabContent} from '../../tabs'; +import {TabsHarness} from './tabs-harness'; + +class TestContentHarness extends ComponentHarness { + static hostSelector = '.test-content'; + async getText(): Promise { + return (await this.host()).text(); + } +} + +describe('TabsHarness', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(() => { + fixture = TestBed.createComponent(TabsHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should load harness with tabs container', async () => { + await expectAsync(loader.getHarness(TabsHarness)).toBeResolved(); + }); + + it('should get tabs', async () => { + const tabs = await loader.getHarness(TabsHarness); + + const tabItems = await tabs.getTabs(); + + expect(tabItems.length).toBe(3); + }); + + it('should get tab panel content via ContentContainerComponentHarness', async () => { + const tabs = await loader.getHarness(TabsHarness); + const tabItems = await tabs.getTabs(); + + const contentHarness = await tabItems[0].getHarness(TestContentHarness); + + expect(await contentHarness.getText()).toBe('Content 1'); + }); + + it('should get selected tab', async () => { + const tabs = await loader.getHarness(TabsHarness); + + const selectedTab = await tabs.getSelectedTab(); + + expect(await selectedTab?.getTitle()).toBe('Tab 1'); + }); + + it('should switch tabs on click', async () => { + const tabs = await loader.getHarness(TabsHarness); + const tabItems = await tabs.getTabs(); + expect(await tabItems[0].isSelected()).toBe(true); + expect(await tabItems[1].isSelected()).toBe(false); + + await tabItems[1].select(); + + expect(await tabItems[0].isSelected()).toBe(false); + expect(await tabItems[1].isSelected()).toBe(true); + }); + + it('should check disabled state', async () => { + const tabs = await loader.getHarness(TabsHarness); + const tabItems = await tabs.getTabs(); + + expect(await tabItems[0].isDisabled()).toBe(false); + expect(await tabItems[2].isDisabled()).toBe(true); + }); + + it('should check active state', async () => { + const tabs = await loader.getHarness(TabsHarness); + const tabItems = await tabs.getTabs(); + + expect(await tabItems[0].isActive()).toBe(true); + expect(await tabItems[1].isActive()).toBe(false); + }); + + it('should filter tabs by title', async () => { + const tabs = await loader.getHarness(TabsHarness); + + const filteredTabs = await tabs.getTabs({title: 'Tab 2'}); + + expect(filteredTabs.length).toBe(1); + expect(await filteredTabs[0].getTitle()).toBe('Tab 2'); + }); + + it('should filter tabs by selected state', async () => { + const tabs = await loader.getHarness(TabsHarness); + + const filteredTabs = await tabs.getTabs({selected: true}); + + expect(filteredTabs.length).toBe(1); + expect(await filteredTabs[0].getTitle()).toBe('Tab 1'); + }); + + it('should filter tabs by disabled state', async () => { + const tabs = await loader.getHarness(TabsHarness); + + const filteredTabs = await tabs.getTabs({disabled: true}); + + expect(filteredTabs.length).toBe(1); + expect(await filteredTabs[0].getTitle()).toBe('Tab 3'); + }); +}); + +@Component({ + template: ` +
+
    +
  • Tab 1
  • +
  • Tab 2
  • +
  • Tab 3
  • +
+ + +
+ +
Content 1
+
+
+
+ Content 2 +
+
+ Content 3 +
+
+ `, + imports: [Tabs, TabList, Tab, TabPanel, TabContent], +}) +class TabsHarnessTest {} diff --git a/src/aria/tabs/testing/tabs-harness.ts b/src/aria/tabs/testing/tabs-harness.ts new file mode 100644 index 000000000000..9bcba08972d1 --- /dev/null +++ b/src/aria/tabs/testing/tabs-harness.ts @@ -0,0 +1,104 @@ +/** + * @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 { + ComponentHarness, + ContentContainerComponentHarness, + HarnessLoader, + HarnessPredicate, +} from '@angular/cdk/testing'; +import {TabsHarnessFilters, TabHarnessFilters} from './tabs-harness-filters'; + +/** Harness for interacting with an Aria tab in tests. */ +export class TabHarness extends ContentContainerComponentHarness { + static hostSelector = '[ngTab]'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a `TabHarness` + * that meets certain criteria. + * @param options Options for filtering which tab instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: TabHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(TabHarness, options) + .addOption('title', options.title, (harness, title) => + HarnessPredicate.stringMatches(harness.getTitle(), title), + ) + .addOption( + 'selected', + options.selected, + async (harness, selected) => (await harness.isSelected()) === selected, + ) + .addOption( + 'disabled', + options.disabled, + async (harness, disabled) => (await harness.isDisabled()) === disabled, + ); + } + + /** Gets the tab's title text. */ + async getTitle(): Promise { + return (await this.host()).text(); + } + + /** Clicks the tab to select it. */ + async select(): Promise { + return (await this.host()).click(); + } + + /** Gets whether the tab is selected. */ + async isSelected(): Promise { + const host = await this.host(); + return (await host.getAttribute('aria-selected')) === 'true'; + } + + /** Gets whether the tab is disabled. */ + async isDisabled(): Promise { + const host = await this.host(); + return (await host.getAttribute('aria-disabled')) === 'true'; + } + + /** Gets whether the tab is active. */ + async isActive(): Promise { + const host = await this.host(); + return (await host.getAttribute('data-active')) === 'true'; + } + + protected override async getRootHarnessLoader(): Promise { + const host = await this.host(); + const controlsId = await host.getAttribute('aria-controls'); + const documentRoot = await this.documentRootLocatorFactory().rootHarnessLoader(); + return await documentRoot.getChildLoader(`[ngTabPanel][id="${controlsId}"]`); + } +} + +/** Harness for interacting with an Aria tabs container in tests. */ +export class TabsHarness extends ComponentHarness { + static hostSelector = '[ngTabs]'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a `TabsHarness` + * that meets certain criteria. + * @param options Options for filtering which tabs instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: TabsHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(TabsHarness, options); + } + + /** Gets all tabs inside the tabs container. */ + async getTabs(filters: TabHarnessFilters = {}): Promise { + return await this.locatorForAll(TabHarness.with(filters))(); + } + + /** Gets the currently selected tab. */ + async getSelectedTab(): Promise { + const tabs = await this.getTabs({selected: true}); + return tabs.length > 0 ? tabs[0] : null; + } +} From d877b49311114b40536caeee5fd45008c237bc63 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Tue, 14 Apr 2026 18:06:02 +0000 Subject: [PATCH 2/2] fixup! feat(aria/tabs): add test harnesses --- goldens/aria/tabs/testing/index.api.md | 1 + src/aria/tabs/testing/tabs-harness.spec.ts | 13 +++++++++++++ src/aria/tabs/testing/tabs-harness.ts | 9 +++++++++ 3 files changed, 23 insertions(+) diff --git a/goldens/aria/tabs/testing/index.api.md b/goldens/aria/tabs/testing/index.api.md index e32a11390adf..c3da3cb64ae3 100644 --- a/goldens/aria/tabs/testing/index.api.md +++ b/goldens/aria/tabs/testing/index.api.md @@ -37,6 +37,7 @@ export class TabsHarness extends ComponentHarness { getTabs(filters?: TabHarnessFilters): Promise; // (undocumented) static hostSelector: string; + selectTab(filters?: TabHarnessFilters): Promise; static with(options?: TabsHarnessFilters): HarnessPredicate; } diff --git a/src/aria/tabs/testing/tabs-harness.spec.ts b/src/aria/tabs/testing/tabs-harness.spec.ts index 3d57c9be40dd..a4d0c8e77186 100644 --- a/src/aria/tabs/testing/tabs-harness.spec.ts +++ b/src/aria/tabs/testing/tabs-harness.spec.ts @@ -71,6 +71,19 @@ describe('TabsHarness', () => { expect(await tabItems[1].isSelected()).toBe(true); }); + it('should select tab matching filters', async () => { + const tabs = await loader.getHarness(TabsHarness); + const tabItems = await tabs.getTabs(); + + expect(await tabItems[0].isSelected()).toBe(true); + expect(await tabItems[1].isSelected()).toBe(false); + + await tabs.selectTab({title: 'Tab 2'}); + + expect(await tabItems[0].isSelected()).toBe(false); + expect(await tabItems[1].isSelected()).toBe(true); + }); + it('should check disabled state', async () => { const tabs = await loader.getHarness(TabsHarness); const tabItems = await tabs.getTabs(); diff --git a/src/aria/tabs/testing/tabs-harness.ts b/src/aria/tabs/testing/tabs-harness.ts index 9bcba08972d1..b2e82c8cf8d9 100644 --- a/src/aria/tabs/testing/tabs-harness.ts +++ b/src/aria/tabs/testing/tabs-harness.ts @@ -101,4 +101,13 @@ export class TabsHarness extends ComponentHarness { const tabs = await this.getTabs({selected: true}); return tabs.length > 0 ? tabs[0] : null; } + + /** Selects a tab matching the given filters. */ + async selectTab(filters: TabHarnessFilters = {}): Promise { + const tabs = await this.getTabs(filters); + if (tabs.length === 0) { + throw new Error(`Could not find tab matching filters: ${JSON.stringify(filters)}`); + } + await tabs[0].select(); + } }