From 8f6c6bdbb7b3a50bb55a602b0a524a1ce9db9208 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Fri, 10 Apr 2026 10:04:30 +0000 Subject: [PATCH 1/3] feat(aria/menu): introduce menu harness --- goldens/aria/menu/testing/index.api.md | 73 ++++++++++++ src/aria/config.bzl | 1 + src/aria/menu/testing/BUILD.bazel | 40 +++++++ src/aria/menu/testing/index.ts | 9 ++ src/aria/menu/testing/menu-harness-filters.ts | 28 +++++ src/aria/menu/testing/menu-harness.spec.ts | 97 ++++++++++++++++ src/aria/menu/testing/menu-harness.ts | 106 ++++++++++++++++++ src/aria/menu/testing/public-api.ts | 10 ++ 8 files changed, 364 insertions(+) create mode 100644 goldens/aria/menu/testing/index.api.md create mode 100644 src/aria/menu/testing/BUILD.bazel create mode 100644 src/aria/menu/testing/index.ts create mode 100644 src/aria/menu/testing/menu-harness-filters.ts create mode 100644 src/aria/menu/testing/menu-harness.spec.ts create mode 100644 src/aria/menu/testing/menu-harness.ts create mode 100644 src/aria/menu/testing/public-api.ts diff --git a/goldens/aria/menu/testing/index.api.md b/goldens/aria/menu/testing/index.api.md new file mode 100644 index 000000000000..67c7cfc04268 --- /dev/null +++ b/goldens/aria/menu/testing/index.api.md @@ -0,0 +1,73 @@ +## API Report File for "@angular/aria_menu_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 { HarnessPredicate } from '@angular/cdk/testing'; + +// @public +export class MenuHarness extends ComponentHarness { + // (undocumented) + getItems(filters?: MenuItemHarnessFilters): Promise; + // (undocumented) + static hostSelector: string; + // (undocumented) + static with(options?: MenuHarnessFilters): HarnessPredicate; +} + +// @public +export interface MenuHarnessFilters extends BaseHarnessFilters { +} + +// @public +export class MenuItemHarness extends ComponentHarness { + // (undocumented) + click(): Promise; + // (undocumented) + getSubmenu(): Promise; + // (undocumented) + getText(): Promise; + // (undocumented) + hasSubmenu(): Promise; + // (undocumented) + static hostSelector: string; + // (undocumented) + isDisabled(): Promise; + // (undocumented) + isExpanded(): Promise; + // (undocumented) + static with(options?: MenuItemHarnessFilters): HarnessPredicate; +} + +// @public +export interface MenuItemHarnessFilters extends BaseHarnessFilters { + disabled?: boolean; + expanded?: boolean; + text?: string | RegExp; +} + +// @public +export class MenuTriggerHarness extends ComponentHarness { + // (undocumented) + click(): Promise; + // (undocumented) + getMenu(filters?: MenuHarnessFilters): Promise; + // (undocumented) + getText(): Promise; + // (undocumented) + static hostSelector: string; + // (undocumented) + static with(options?: MenuTriggerHarnessFilters): HarnessPredicate; +} + +// @public +export interface MenuTriggerHarnessFilters extends BaseHarnessFilters { + text?: string | RegExp; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/src/aria/config.bzl b/src/aria/config.bzl index 10069d32848a..fb6913d0a831 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -5,6 +5,7 @@ ARIA_ENTRYPOINTS = [ "grid", "listbox", "menu", + "menu/testing", "tabs", "toolbar", "tree", diff --git a/src/aria/menu/testing/BUILD.bazel b/src/aria/menu/testing/BUILD.bazel new file mode 100644 index 000000000000..c20fd6664983 --- /dev/null +++ b/src/aria/menu/testing/BUILD.bazel @@ -0,0 +1,40 @@ +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/core", + "//:node_modules/@angular/platform-browser", + "//src/aria/menu", + "//src/cdk/testing", + "//src/cdk/testing/testbed", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [ + ":unit_tests_lib", + ], +) diff --git a/src/aria/menu/testing/index.ts b/src/aria/menu/testing/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/aria/menu/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/menu/testing/menu-harness-filters.ts b/src/aria/menu/testing/menu-harness-filters.ts new file mode 100644 index 000000000000..416a84b9eeca --- /dev/null +++ b/src/aria/menu/testing/menu-harness-filters.ts @@ -0,0 +1,28 @@ +/** + * @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'; + +/** Filters for locating a `MenuTriggerHarness`. */ +export interface MenuTriggerHarnessFilters extends BaseHarnessFilters { + /** Only find instances whose text matches the given value. */ + text?: string | RegExp; +} + +/** Filters for locating a `MenuHarness`. */ +export interface MenuHarnessFilters extends BaseHarnessFilters {} + +/** Filters for locating a `MenuItemHarness`. */ +export interface MenuItemHarnessFilters extends BaseHarnessFilters { + /** Only find instances whose text matches the given value. */ + text?: string | RegExp; + /** Only find instances whose disabled state matches the given value. */ + disabled?: boolean; + /** Only find instances whose expanded state matches the given value. */ + expanded?: boolean; +} diff --git a/src/aria/menu/testing/menu-harness.spec.ts b/src/aria/menu/testing/menu-harness.spec.ts new file mode 100644 index 000000000000..92d56920c811 --- /dev/null +++ b/src/aria/menu/testing/menu-harness.spec.ts @@ -0,0 +1,97 @@ +/** + * @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 {TestBed} from '@angular/core/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {Menu} from '../menu'; +import {MenuItem} from '../menu-item'; +import {MenuTrigger} from '../menu-trigger'; +import {MenuItemHarness, MenuTriggerHarness} from './menu-harness'; + +describe('Aria Menu Harness', () => { + let fixture: any; + let loader: any; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [Menu, MenuItem, MenuTrigger, MenuTestApp], + }); + + fixture = TestBed.createComponent(MenuTestApp); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should locate the menu trigger harness', async () => { + const trigger = await loader.getHarness(MenuTriggerHarness.with({text: 'Open Menu'})); + expect(trigger).toBeTruthy(); + expect(await trigger.getText()).toBe('Open Menu'); + }); + + it('should open the menu and locate items', async () => { + const trigger = await loader.getHarness(MenuTriggerHarness); + await trigger.click(); + fixture.detectChanges(); + + const menu = await trigger.getMenu(); + expect(menu).toBeTruthy(); + + const items = await menu.getItems(); + expect(items.length).toBe(4); + expect(await items[0].getText()).toBe('Item 1'); + expect(await items[1].getText()).toBe('Item 2'); + expect(await items[2].getText()).toBe('Submenu'); + expect(await items[3].getText()).toBe('Nested Item'); + }); + + it('should filter menu items by state', async () => { + const trigger = await loader.getHarness(MenuTriggerHarness); + await trigger.click(); + fixture.detectChanges(); + + const disabledItems = await loader.getAllHarnesses(MenuItemHarness.with({disabled: true})); + expect(disabledItems.length).toBe(1); + expect(await disabledItems[0].getText()).toBe('Item 2'); + }); + + it('should locate and open a nested submenu', async () => { + const mainTrigger = await loader.getHarness(MenuTriggerHarness.with({text: 'Open Menu'})); + await mainTrigger.click(); + fixture.detectChanges(); + + const subItem = await loader.getHarness(MenuItemHarness.with({text: 'Submenu'})); + expect(await subItem.hasSubmenu()).toBe(true); + await subItem.click(); + fixture.detectChanges(); + + const submenu = await subItem.getSubmenu(); + expect(submenu).toBeTruthy(); + const subItems = await submenu!.getItems(); + expect(subItems.length).toBe(1); + expect(await subItems[0].getText()).toBe('Nested Item'); + }); +}); + +@Component({ + template: ` + + +
+
Item 1
+
Item 2
+
Submenu
+ +
+
Nested Item
+
+
+ `, + imports: [Menu, MenuItem, MenuTrigger], +}) +class MenuTestApp {} diff --git a/src/aria/menu/testing/menu-harness.ts b/src/aria/menu/testing/menu-harness.ts new file mode 100644 index 000000000000..4d9809d3661f --- /dev/null +++ b/src/aria/menu/testing/menu-harness.ts @@ -0,0 +1,106 @@ +/** + * @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, HarnessPredicate} from '@angular/cdk/testing'; +import { + MenuHarnessFilters, + MenuItemHarnessFilters, + MenuTriggerHarnessFilters, +} from './menu-harness-filters'; + +/** Harness for interacting with a standard ngMenuItem in tests. */ +export class MenuItemHarness extends ComponentHarness { + static hostSelector = '[ngMenuItem]'; + + static with(options: MenuItemHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MenuItemHarness, options) + .addOption('text', options.text, (harness, text) => + HarnessPredicate.stringMatches(harness.getText(), text), + ) + .addOption( + 'disabled', + options.disabled, + async (harness, disabled) => (await harness.isDisabled()) === disabled, + ) + .addOption( + 'expanded', + options.expanded, + async (harness, expanded) => (await harness.isExpanded()) === expanded, + ); + } + + async getText(): Promise { + return (await this.host()).text(); + } + + async isDisabled(): Promise { + const host = await this.host(); + return (await host.getAttribute('aria-disabled')) === 'true'; + } + + async isExpanded(): Promise { + const host = await this.host(); + return (await host.getAttribute('aria-expanded')) === 'true'; + } + + async click(): Promise { + return (await this.host()).click(); + } + + async hasSubmenu(): Promise { + return (await (await this.host()).getAttribute('aria-haspopup')) !== null; + } + + async getSubmenu(): Promise { + if (await this.hasSubmenu()) { + const controlsId = await (await this.host()).getAttribute('aria-controls'); + return this.documentRootLocatorFactory().locatorFor( + MenuHarness.with({selector: controlsId ? `#${controlsId}` : undefined}), + )(); + } + return null; + } +} + +/** Harness for interacting with a standard ngMenu in tests. */ +export class MenuHarness extends ComponentHarness { + static hostSelector = '[ngMenu]'; + + static with(options: MenuHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MenuHarness, options); + } + + async getItems(filters: MenuItemHarnessFilters = {}): Promise { + return this.locatorForAll(MenuItemHarness.with(filters))(); + } +} + +/** Harness for interacting with a standard ngMenuTrigger in tests. */ +export class MenuTriggerHarness extends ComponentHarness { + static hostSelector = '[ngMenuTrigger]'; + + static with(options: MenuTriggerHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MenuTriggerHarness, options).addOption( + 'text', + options.text, + (harness, text) => HarnessPredicate.stringMatches(harness.getText(), text), + ); + } + + async getText(): Promise { + return (await this.host()).text(); + } + + async click(): Promise { + return (await this.host()).click(); + } + + async getMenu(filters: MenuHarnessFilters = {}): Promise { + return this.documentRootLocatorFactory().locatorFor(MenuHarness.with(filters))(); + } +} diff --git a/src/aria/menu/testing/public-api.ts b/src/aria/menu/testing/public-api.ts new file mode 100644 index 000000000000..2a408c036830 --- /dev/null +++ b/src/aria/menu/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 './menu-harness'; +export * from './menu-harness-filters'; From d8ab182f00115ae2b9ec7e02d64df3099718cc83 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Fri, 10 Apr 2026 19:13:04 +0000 Subject: [PATCH 2/3] fixup! feat(aria/menu): introduce menu harness --- goldens/aria/menu/testing/index.api.md | 33 +----- src/aria/menu/testing/menu-harness-filters.ts | 11 +- src/aria/menu/testing/menu-harness.spec.ts | 110 +++++++++++++----- src/aria/menu/testing/menu-harness.ts | 88 ++++++++------ 4 files changed, 145 insertions(+), 97 deletions(-) diff --git a/goldens/aria/menu/testing/index.api.md b/goldens/aria/menu/testing/index.api.md index 67c7cfc04268..a9a44f0ecec2 100644 --- a/goldens/aria/menu/testing/index.api.md +++ b/goldens/aria/menu/testing/index.api.md @@ -7,36 +7,34 @@ import { BaseHarnessFilters } from '@angular/cdk/testing'; import { ComponentHarness } from '@angular/cdk/testing'; import { HarnessPredicate } from '@angular/cdk/testing'; +import { TestElement } from '@angular/cdk/testing'; // @public export class MenuHarness extends ComponentHarness { - // (undocumented) + close(): Promise; getItems(filters?: MenuItemHarnessFilters): Promise; + _getTrigger(): Promise; // (undocumented) static hostSelector: string; + isOpen(): Promise; + open(): Promise; // (undocumented) static with(options?: MenuHarnessFilters): HarnessPredicate; } // @public export interface MenuHarnessFilters extends BaseHarnessFilters { + triggerText?: string | RegExp; } // @public export class MenuItemHarness extends ComponentHarness { - // (undocumented) click(): Promise; - // (undocumented) getSubmenu(): Promise; - // (undocumented) getText(): Promise; // (undocumented) - hasSubmenu(): Promise; - // (undocumented) static hostSelector: string; - // (undocumented) isDisabled(): Promise; - // (undocumented) isExpanded(): Promise; // (undocumented) static with(options?: MenuItemHarnessFilters): HarnessPredicate; @@ -49,25 +47,6 @@ export interface MenuItemHarnessFilters extends BaseHarnessFilters { text?: string | RegExp; } -// @public -export class MenuTriggerHarness extends ComponentHarness { - // (undocumented) - click(): Promise; - // (undocumented) - getMenu(filters?: MenuHarnessFilters): Promise; - // (undocumented) - getText(): Promise; - // (undocumented) - static hostSelector: string; - // (undocumented) - static with(options?: MenuTriggerHarnessFilters): HarnessPredicate; -} - -// @public -export interface MenuTriggerHarnessFilters extends BaseHarnessFilters { - text?: string | RegExp; -} - // (No @packageDocumentation comment for this package) ``` diff --git a/src/aria/menu/testing/menu-harness-filters.ts b/src/aria/menu/testing/menu-harness-filters.ts index 416a84b9eeca..7700e9f03480 100644 --- a/src/aria/menu/testing/menu-harness-filters.ts +++ b/src/aria/menu/testing/menu-harness-filters.ts @@ -8,14 +8,11 @@ import {BaseHarnessFilters} from '@angular/cdk/testing'; -/** Filters for locating a `MenuTriggerHarness`. */ -export interface MenuTriggerHarnessFilters extends BaseHarnessFilters { - /** Only find instances whose text matches the given value. */ - text?: string | RegExp; -} - /** Filters for locating a `MenuHarness`. */ -export interface MenuHarnessFilters extends BaseHarnessFilters {} +export interface MenuHarnessFilters extends BaseHarnessFilters { + /** Only find instances whose trigger text matches the given value. */ + triggerText?: string | RegExp; +} /** Filters for locating a `MenuItemHarness`. */ export interface MenuItemHarnessFilters extends BaseHarnessFilters { diff --git a/src/aria/menu/testing/menu-harness.spec.ts b/src/aria/menu/testing/menu-harness.spec.ts index 92d56920c811..dab5d065ad04 100644 --- a/src/aria/menu/testing/menu-harness.spec.ts +++ b/src/aria/menu/testing/menu-harness.spec.ts @@ -10,9 +10,11 @@ import {Component} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; import {Menu} from '../menu'; +import {MenuContent} from '../menu-content'; import {MenuItem} from '../menu-item'; import {MenuTrigger} from '../menu-trigger'; -import {MenuItemHarness, MenuTriggerHarness} from './menu-harness'; +import {MenuBar} from '../menu-bar'; +import {MenuItemHarness, MenuHarness} from './menu-harness'; describe('Aria Menu Harness', () => { let fixture: any; @@ -20,7 +22,7 @@ describe('Aria Menu Harness', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [Menu, MenuItem, MenuTrigger, MenuTestApp], + imports: [Menu, MenuItem, MenuTrigger, MenuBar, MenuContent, MenuTestApp], }); fixture = TestBed.createComponent(MenuTestApp); @@ -28,31 +30,47 @@ describe('Aria Menu Harness', () => { loader = TestbedHarnessEnvironment.loader(fixture); }); - it('should locate the menu trigger harness', async () => { - const trigger = await loader.getHarness(MenuTriggerHarness.with({text: 'Open Menu'})); - expect(trigger).toBeTruthy(); - expect(await trigger.getText()).toBe('Open Menu'); + it('should locate the menu harness', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + expect(menu).toBeTruthy(); + }); + + it('should verify that the menu is initially closed', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + expect(await menu.isOpen()).toBe(false); }); - it('should open the menu and locate items', async () => { - const trigger = await loader.getHarness(MenuTriggerHarness); - await trigger.click(); + it('should open the menu', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); fixture.detectChanges(); - const menu = await trigger.getMenu(); - expect(menu).toBeTruthy(); + expect(await menu.isOpen()).toBe(true); + }); + + it('should close the menu', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); + fixture.detectChanges(); + + await menu.close(); + fixture.detectChanges(); + expect(await menu.isOpen()).toBe(false); + }); + + it('should get all items inside an open menu', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); + fixture.detectChanges(); const items = await menu.getItems(); - expect(items.length).toBe(4); + expect(items.length).toBe(3); expect(await items[0].getText()).toBe('Item 1'); - expect(await items[1].getText()).toBe('Item 2'); - expect(await items[2].getText()).toBe('Submenu'); - expect(await items[3].getText()).toBe('Nested Item'); }); - it('should filter menu items by state', async () => { - const trigger = await loader.getHarness(MenuTriggerHarness); - await trigger.click(); + it('should filter menu items by their disabled state', async () => { + const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await menu.open(); fixture.detectChanges(); const disabledItems = await loader.getAllHarnesses(MenuItemHarness.with({disabled: true})); @@ -60,22 +78,49 @@ describe('Aria Menu Harness', () => { expect(await disabledItems[0].getText()).toBe('Item 2'); }); - it('should locate and open a nested submenu', async () => { - const mainTrigger = await loader.getHarness(MenuTriggerHarness.with({text: 'Open Menu'})); - await mainTrigger.click(); + it('should locate and interact with nested submenus', async () => { + const main = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await main.open(); fixture.detectChanges(); const subItem = await loader.getHarness(MenuItemHarness.with({text: 'Submenu'})); - expect(await subItem.hasSubmenu()).toBe(true); await subItem.click(); fixture.detectChanges(); const submenu = await subItem.getSubmenu(); expect(submenu).toBeTruthy(); + expect(await submenu!.isOpen()).toBe(true); + }); + + it('should read items within a nested submenu', async () => { + const main = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); + await main.open(); + fixture.detectChanges(); + + const subItem = await loader.getHarness(MenuItemHarness.with({text: 'Submenu'})); + await subItem.click(); + fixture.detectChanges(); + + const submenu = await subItem.getSubmenu(); const subItems = await submenu!.getItems(); expect(subItems.length).toBe(1); expect(await subItems[0].getText()).toBe('Nested Item'); }); + + it('should confirm persistent horizontal menu bars are always open', async () => { + const menubar = await loader.getHarness(MenuHarness.with({selector: '[ngMenuBar]'})); + expect(menubar).toBeTruthy(); + expect(await menubar.isOpen()).toBe(true); + }); + + it('should read items from a persistent horizontal menu bar', async () => { + const menubar = await loader.getHarness(MenuHarness.with({selector: '[ngMenuBar]'})); + const items = await menubar.getItems(); + + expect(items.length).toBe(2); + expect(await items[0].getText()).toBe('File'); + expect(await items[1].getText()).toBe('Edit'); + }); }); @Component({ @@ -83,15 +128,24 @@ describe('Aria Menu Harness', () => {
-
Item 1
-
Item 2
-
Submenu
+ +
Item 1
+
Item 2
+
Submenu
+
+
-
+
+
Nested Item
-
+ +
+ +
+
File
+
Edit
`, - imports: [Menu, MenuItem, MenuTrigger], + imports: [Menu, MenuItem, MenuTrigger, MenuBar, MenuContent], }) class MenuTestApp {} diff --git a/src/aria/menu/testing/menu-harness.ts b/src/aria/menu/testing/menu-harness.ts index 4d9809d3661f..8980d54928be 100644 --- a/src/aria/menu/testing/menu-harness.ts +++ b/src/aria/menu/testing/menu-harness.ts @@ -6,12 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing'; -import { - MenuHarnessFilters, - MenuItemHarnessFilters, - MenuTriggerHarnessFilters, -} from './menu-harness-filters'; +import {ComponentHarness, HarnessPredicate, TestElement} from '@angular/cdk/testing'; +import {MenuHarnessFilters, MenuItemHarnessFilters} from './menu-harness-filters'; /** Harness for interacting with a standard ngMenuItem in tests. */ export class MenuItemHarness extends ComponentHarness { @@ -34,73 +30,95 @@ export class MenuItemHarness extends ComponentHarness { ); } + /** Gets the text content of the menu item. */ async getText(): Promise { return (await this.host()).text(); } + /** Whether the menu item is disabled. */ async isDisabled(): Promise { const host = await this.host(); return (await host.getAttribute('aria-disabled')) === 'true'; } + /** Whether the menu item is expanded (contains an open submenu). */ async isExpanded(): Promise { const host = await this.host(); return (await host.getAttribute('aria-expanded')) === 'true'; } + /** Clicks the menu item to trigger its action or toggle its submenu. */ async click(): Promise { return (await this.host()).click(); } - async hasSubmenu(): Promise { - return (await (await this.host()).getAttribute('aria-haspopup')) !== null; - } - + /** Resolves the nested submenu panel associated with this menu item, if any exists. */ async getSubmenu(): Promise { - if (await this.hasSubmenu()) { - const controlsId = await (await this.host()).getAttribute('aria-controls'); + const controlsId = await (await this.host()).getAttribute('aria-controls'); + if (controlsId) { return this.documentRootLocatorFactory().locatorFor( - MenuHarness.with({selector: controlsId ? `#${controlsId}` : undefined}), + MenuHarness.with({selector: `#${controlsId}`}), )(); } return null; } } -/** Harness for interacting with a standard ngMenu in tests. */ +/** Harness for interacting with a standard ngMenu or ngMenuBar in tests. */ export class MenuHarness extends ComponentHarness { - static hostSelector = '[ngMenu]'; + static hostSelector = '[ngMenu], [ngMenuBar]'; static with(options: MenuHarnessFilters = {}): HarnessPredicate { - return new HarnessPredicate(MenuHarness, options); + return new HarnessPredicate(MenuHarness, options).addOption( + 'triggerText', + options.triggerText, + async (harness, text) => { + const trigger = await harness._getTrigger(); + if (!trigger) return false; + return HarnessPredicate.stringMatches(await trigger.text(), text); + }, + ); } - async getItems(filters: MenuItemHarnessFilters = {}): Promise { - return this.locatorForAll(MenuItemHarness.with(filters))(); + /** Resolves the trigger associated with this menu container via aria-controls inversion. */ + async _getTrigger(): Promise { + const id = await (await this.host()).getAttribute('id'); + if (!id) return null; + return this.documentRootLocatorFactory().locatorForOptional(`[aria-controls="${id}"]`)(); } -} - -/** Harness for interacting with a standard ngMenuTrigger in tests. */ -export class MenuTriggerHarness extends ComponentHarness { - static hostSelector = '[ngMenuTrigger]'; - static with(options: MenuTriggerHarnessFilters = {}): HarnessPredicate { - return new HarnessPredicate(MenuTriggerHarness, options).addOption( - 'text', - options.text, - (harness, text) => HarnessPredicate.stringMatches(harness.getText(), text), - ); + /** Checks whether the menu container is visible. */ + async isOpen(): Promise { + const host = await this.host(); + // Menu bars are always visible persistently. + if (await host.matchesSelector('[ngMenuBar]')) { + return true; + } + return (await host.getAttribute('data-visible')) === 'true'; } - async getText(): Promise { - return (await this.host()).text(); + /** Opens the menu if it is currently closed. */ + async open(): Promise { + if (!(await this.isOpen())) { + const trigger = await this._getTrigger(); + if (trigger) { + await trigger.click(); + } + } } - async click(): Promise { - return (await this.host()).click(); + /** Closes the menu if it is currently open. */ + async close(): Promise { + if (await this.isOpen()) { + const trigger = await this._getTrigger(); + if (trigger) { + await trigger.click(); + } + } } - async getMenu(filters: MenuHarnessFilters = {}): Promise { - return this.documentRootLocatorFactory().locatorFor(MenuHarness.with(filters))(); + /** Queries all menu items inside this menu container. */ + async getItems(filters: MenuItemHarnessFilters = {}): Promise { + return this.locatorForAll(MenuItemHarness.with(filters))(); } } From 2c263567810184069a2e4bd4fe1b8c0f9e9a406d Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Fri, 10 Apr 2026 21:02:04 +0000 Subject: [PATCH 3/3] fixup! feat(aria/menu): introduce menu harness --- src/aria/menu/testing/menu-harness.spec.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/aria/menu/testing/menu-harness.spec.ts b/src/aria/menu/testing/menu-harness.spec.ts index dab5d065ad04..8d4dbbe5b7ad 100644 --- a/src/aria/menu/testing/menu-harness.spec.ts +++ b/src/aria/menu/testing/menu-harness.spec.ts @@ -7,7 +7,8 @@ */ import {Component} from '@angular/core'; -import {TestBed} from '@angular/core/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HarnessLoader} from '@angular/cdk/testing'; import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; import {Menu} from '../menu'; import {MenuContent} from '../menu-content'; @@ -17,22 +18,19 @@ import {MenuBar} from '../menu-bar'; import {MenuItemHarness, MenuHarness} from './menu-harness'; describe('Aria Menu Harness', () => { - let fixture: any; - let loader: any; + let fixture: ComponentFixture; + let loader: HarnessLoader; beforeEach(() => { - TestBed.configureTestingModule({ - imports: [Menu, MenuItem, MenuTrigger, MenuBar, MenuContent, MenuTestApp], - }); - fixture = TestBed.createComponent(MenuTestApp); fixture.detectChanges(); loader = TestbedHarnessEnvironment.loader(fixture); }); it('should locate the menu harness', async () => { - const menu = await loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})); - expect(menu).toBeTruthy(); + await expectAsync( + loader.getHarness(MenuHarness.with({triggerText: 'Open Menu'})), + ).toBeResolved(); }); it('should verify that the menu is initially closed', async () => { @@ -88,7 +86,6 @@ describe('Aria Menu Harness', () => { fixture.detectChanges(); const submenu = await subItem.getSubmenu(); - expect(submenu).toBeTruthy(); expect(await submenu!.isOpen()).toBe(true); }); @@ -109,7 +106,6 @@ describe('Aria Menu Harness', () => { it('should confirm persistent horizontal menu bars are always open', async () => { const menubar = await loader.getHarness(MenuHarness.with({selector: '[ngMenuBar]'})); - expect(menubar).toBeTruthy(); expect(await menubar.isOpen()).toBe(true); });