From e8d2a7b69af80d46cbfbbc9beebfae90fab6298b Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Mon, 12 Oct 2026 11:14:33 -0500 Subject: [PATCH 1/5] pilot browser locators in Button and TreeView tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3051e4e7-3e74-4dcd-b1f4-1314e3b9b56e --- contributor-docs/testing.md | 68 +- .../src/Button/__tests__/Button.test.tsx | 516 ++++---- packages/react/src/TreeView/TreeView.test.tsx | 1035 ++++++++++------- 3 files changed, 965 insertions(+), 654 deletions(-) diff --git a/contributor-docs/testing.md b/contributor-docs/testing.md index a31c734a871..fce3faa3bd2 100644 --- a/contributor-docs/testing.md +++ b/contributor-docs/testing.md @@ -5,22 +5,23 @@ ## Table of Contents -* [Strategy](#strategy) -* [Unit Tests](#unit-tests) - * [Overview](#overview) - * [Running Tests](#running-tests) -* [Visual Regression Tests](#visual-regression-tests) - * [Overview](#overview) - * [Prerequisites](#prerequisites) - * [Components](#components) - * [Visual Regression Testing](#visual-regression-testing) - * [Accessibility Verification Testing](#accessibility-verification-testing) -* [Interaction Tests](#interaction-tests) - * [As A Part Of Unit Tests](#as-a-part-of-unit-tests) - * [Storybook Interaction Tests](#storybook-interaction-tests) -* [Continuous Integration](#continuous-integration) -* [FAQ](#faq) - * [Why am I seeing `browserType.launch: Executable doesn't exist at ../path`?](#why-am-i-seeing-browsertypelaunch-executable-doesnt-exist-at-path) +- [Strategy](#strategy) +- [Unit Tests](#unit-tests) + - [Overview](#overview) + - [Browser locator pilot](#browser-locator-pilot) + - [Snapshots](#snapshots) + - [Running Tests](#running-tests) +- [Interaction Tests](#interaction-tests) + - [As A Part Of Unit Tests](#as-a-part-of-unit-tests) + - [Storybook Interaction Tests](#storybook-interaction-tests) +- [Visual Regression Tests](#visual-regression-tests) + - [Prerequisites](#prerequisites) + - [Components](#components) + - [Visual Regression Testing](#visual-regression-testing) + - [Accessibility Verification Testing](#accessibility-verification-testing) +- [Continuous Integration](#continuous-integration) +- [FAQ](#faq) + - [Why am I seeing `browserType.launch: Executable doesn't exist at ../path`?](#why-am-i-seeing-browsertypelaunch-executable-doesnt-exist-at-path) @@ -74,6 +75,39 @@ To make assertions about the elements we use [Vitest](https://vitest.dev/) and [ \*: Read about the [differences between `fireEvent` and `UserEvent`](https://testing-library.com/docs/user-event/intro/#differences-from-fireevent). +### Browser locator pilot + +The Button and TreeView suites pilot [Vitest Browser locators](https://vitest.dev/api/browser/locators.html) and browser-backed interactions. Component tests already run in Chromium through Vitest's Playwright provider; this pilot changes the test APIs, not the runner or browser environment. + +Keep RTL for `render`, `rerender`, `unmount`, `renderHook`, cleanup, and explicit React `act` boundaries. Import browser queries and interactions from `vitest/browser`: + +```tsx +import {render} from '@testing-library/react' +import {expect, it, vi} from 'vitest' +import {page, userEvent} from 'vitest/browser' +import {Button} from '@primer/react' + +it('calls onClick', async () => { + const onClick = vi.fn() + render() + + const button = page.getByRole('button', {name: 'Submit', exact: true}) + await expect.element(button).toBeVisible() + await userEvent.click(button) + expect(onClick).toHaveBeenCalledTimes(1) +}) +``` + +Use `expect.element` for eventual DOM assertions and `expect.poll` for eventual computed values, such as live-region messages. Keep ordinary `expect` for synchronous contracts. Prefer role and accessible-name queries, but don't change what a test asserts just to remove a DOM lookup: + +- Locators resolve again when used. An expanded TreeView can make a previously unique partial name match several items. Use an exact name or scope the locator; don't hide ambiguity with `.first()`. +- DOM presence and visibility are different contracts. Button's loading announcement is checked by its exact ID and text because the spinner also contains "Loading". +- Keep intentional synthetic events for disabled-click suppression, event cancellation, and specific event payloads. Browser actions wait for actionable elements and don't expose the result of `dispatchEvent`. +- Keep programmatic focus in `act` when testing focus redirection. Preserve fake timers and synchronous assertions after explicit timer advancement. +- Use `.element()` for snapshots, computed styles, structural checks, or capturing a particular node before it unmounts. A locator follows current DOM state; it doesn't retain the identity of a detached node. + +The pilot leaves shared helpers, Node/SSR tests, and the separate Playwright visual and accessibility suites unchanged. It doesn't require removing RTL dependencies. + ### Snapshots We are slowly moving away from using snapshots as a way to test visual changes of our components. You can read more about the decision in this [ADR](https://github.com/primer/react/blob/main/contributor-docs/adrs/adr-011-snapshot-tests.md). We are in the process of migrating our existing snapshot tests to use [Playwright](https://playwright.dev/) for visual regression testing. If you are writing a new test and you need to test the visual changes of the component, please refer to the [Visual Regression Tests](#visual-regression-tests) section. @@ -98,7 +132,7 @@ We write user interaction tests leveraging [@testing-library/react](https://test ### Storybook Interaction Tests -We use [Storybook interactions tests](https://storybook.js.org/docs/react/writing-tests/interaction-testing) to simulate and test some complex user interactions. They are particularly useful for cases where writing unit tests is not practical due to the limitation of the mock browser functionalities of JSDOM. For example testing out a component's overflow behaviour whose responsiveness is managed by its own dynamic width. +We use [Storybook interactions tests](https://storybook.js.org/docs/react/writing-tests/interaction-testing) to simulate and test complex user interactions in story scenarios. For example, testing a component's overflow behaviour whose responsiveness is managed by its own dynamic width. Storybook tests are authored within the components's source directory with the file name of `interactions.stories.tsx` diff --git a/packages/react/src/Button/__tests__/Button.test.tsx b/packages/react/src/Button/__tests__/Button.test.tsx index f4f4ded2486..f5c1648093a 100644 --- a/packages/react/src/Button/__tests__/Button.test.tsx +++ b/packages/react/src/Button/__tests__/Button.test.tsx @@ -1,5 +1,6 @@ +import {page, userEvent} from 'vitest/browser' import {SearchIcon, HeartIcon} from '@primer/octicons-react' -import {render, screen, fireEvent} from '@testing-library/react' +import {render, fireEvent} from '@testing-library/react' import {describe, it, expect, vi} from 'vitest' import React from 'react' import {IconButton, Button, LinkButton} from '../../Button' @@ -35,48 +36,49 @@ describe('Button', () => { implementsClassName(Button, classes.ButtonBase) it('renders a ) - const button = container.getByRole('button') - expect(button.textContent).toEqual('Default') + render() + const button = page.getByRole('button') + expect(button.element().textContent).toEqual('Default') }) - it('preserves "onClick" prop', () => { + it('preserves "onClick" prop', async () => { const onClick = vi.fn() - const container = render() - const button = container.getByRole('button') - fireEvent.click(button) + render() + const button = page.getByRole('button') + await userEvent.click(button) expect(onClick).toHaveBeenCalledTimes(1) }) it('respects block prop', () => { - const container = render( + render( , ) - const button = container.getByRole('button') - expect(button).toMatchSnapshot() + + const button = page.getByRole('button') + expect(button.element()).toMatchSnapshot() }) - it('displays a notification indicator on the button boundary', () => { - const {getByRole} = render() - expect(getByRole('button')).toHaveAttribute('data-notification-indicator', 'button') + it('displays a notification indicator on the button boundary', async () => { + render() + await expect.element(page.getByRole('button')).toHaveAttribute('data-notification-indicator', 'button') }) - it('displays a notification indicator on the leading visual', () => { - const {getByRole} = render( + it('displays a notification indicator on the leading visual', async () => { + render( , ) - expect(getByRole('button')).toHaveAttribute('data-notification-indicator', 'leadingVisual') + + await expect.element(page.getByRole('button')).toHaveAttribute('data-notification-indicator', 'leadingVisual') }) - it('displays a notification indicator on an icon button icon', () => { - const {getByRole} = render( - , - ) - expect(getByRole('button')).toHaveAttribute('data-notification-indicator', 'icon') + it('displays a notification indicator on an icon button icon', async () => { + render() + + await expect.element(page.getByRole('button')).toHaveAttribute('data-notification-indicator', 'icon') }) it('warns when a leading visual notification indicator has no leading visual', () => { @@ -92,169 +94,185 @@ describe('Button', () => { it('respects the "disabled" prop', () => { const onClick = vi.fn() - const container = render( + + render( , ) - const button = container.getByRole('button') - expect(button.hasAttribute('disabled')).toEqual(true) - fireEvent.click(button) + + const button = page.getByRole('button') + expect(button.element().hasAttribute('disabled')).toEqual(true) + fireEvent.click(button.element()) expect(onClick).toHaveBeenCalledTimes(0) }) it('respects the small size prop', () => { - const container = render( + render( , ) - const button = container.getByRole('button') - expect(button).toMatchSnapshot() + + const button = page.getByRole('button') + expect(button.element()).toMatchSnapshot() }) it('respects the large size prop', () => { - const container = render( + render( , ) - const button = container.getByRole('button') - expect(button).toMatchSnapshot() + + const button = page.getByRole('button') + expect(button.element()).toMatchSnapshot() }) it('styles primary button appropriately', () => { - const container = render( + render( , ) - const button = container.getByRole('button') - expect(button).toMatchSnapshot() + + const button = page.getByRole('button') + expect(button.element()).toMatchSnapshot() }) it('styles invisible button appropriately', () => { - const container = render( + render( , ) - const button = container.getByRole('button') - expect(button).toMatchSnapshot() + + const button = page.getByRole('button') + expect(button.element()).toMatchSnapshot() }) it('styles danger button appropriately', () => { - const container = render( + render( , ) - const button = container.getByRole('button') - expect(button).toMatchSnapshot() + + const button = page.getByRole('button') + expect(button.element()).toMatchSnapshot() }) - it('makes sure icon button has an aria-label', () => { - const container = render() - const IconOnlyButton = container.getByLabelText('Search button') - expect(IconOnlyButton).toBeTruthy() + it('makes sure icon button has an aria-label', async () => { + render() + const IconOnlyButton = page.getByLabelText('Search button', { + exact: true, + }) + await expect.element(IconOnlyButton).toBeInTheDocument() }) it('respects the alignContent prop', () => { - const container = render( + render( , ) - const button = container.getByRole('button') - expect(button).toMatchSnapshot() + + const button = page.getByRole('button') + expect(button.element()).toMatchSnapshot() }) - it('should render the leadingVisual prop before the button content', () => { + it('should render the leadingVisual prop before the button content', async () => { render( , ) - expect(screen.getByTestId('leadingVisual')).toBeInTheDocument() + await expect.element(page.getByTestId('leadingVisual')).toBeInTheDocument() - const position = screen.getByText('content').compareDocumentPosition(screen.getByTestId('leadingVisual')) + const position = page + .getByText('content', { + exact: true, + }) + .element() + .compareDocumentPosition(page.getByTestId('leadingVisual').element()) expect(position).toBe(Node.DOCUMENT_POSITION_PRECEDING) }) - it('should render the trailingVisual prop after the button content', () => { + it('should render the trailingVisual prop after the button content', async () => { render( , ) - expect(screen.getByTestId('trailingVisual')).toBeInTheDocument() - - const position = screen.getByText('content').compareDocumentPosition(screen.getByTestId('trailingVisual')) + await expect.element(page.getByTestId('trailingVisual')).toBeInTheDocument() + + const position = page + .getByText('content', { + exact: true, + }) + .element() + .compareDocumentPosition(page.getByTestId('trailingVisual').element()) expect(position).toBe(Node.DOCUMENT_POSITION_FOLLOWING) }) it('should describe the button with a default loading announcement, and only when the button is in a loading state', async () => { const buttonId = 'loading-button' - const container = render( + + render( content , ) - const buttonNode = container.getByRole('button') - fireEvent.click(buttonNode) + const buttonNode = page.getByRole('button') - expect(buttonNode.getAttribute('aria-describedby')).toBe(`${buttonId}-loading-announcement`) + await userEvent.click(buttonNode) - fireEvent.click(buttonNode) + await expect.element(buttonNode).toHaveAttribute('aria-describedby', `${buttonId}-loading-announcement`) - // Wait for the loading state to be set - await new Promise(resolve => setTimeout(resolve, 10)) + fireEvent.click(buttonNode.element()) - // Check that the loading announcement element is present - const loadingAnnouncement = document.getElementById(`${buttonId}-loading-announcement`) - expect(loadingAnnouncement).toBeInTheDocument() - expect(loadingAnnouncement?.textContent).toBe('Loading') + await expect.poll(() => document.getElementById(`${buttonId}-loading-announcement`)?.textContent).toBe('Loading') }) it('should render a custom loading announcement, and only when the button is in a loading state', async () => { const buttonId = 'loading-button' - const container = render( + + render( content , ) - const buttonNode = container.getByRole('button') - fireEvent.click(buttonNode) + const buttonNode = page.getByRole('button') - expect(buttonNode.getAttribute('aria-describedby')).toBe(`${buttonId}-loading-announcement`) + await userEvent.click(buttonNode) - fireEvent.click(buttonNode) + await expect.element(buttonNode).toHaveAttribute('aria-describedby', `${buttonId}-loading-announcement`) - // Wait for the loading state to be set - await new Promise(resolve => setTimeout(resolve, 10)) + fireEvent.click(buttonNode.element()) - // Check that the loading announcement element is present with custom text - const loadingAnnouncement = document.getElementById(`${buttonId}-loading-announcement`) - expect(loadingAnnouncement).toBeInTheDocument() - expect(loadingAnnouncement?.textContent).toBe('Action loading') + await expect + .poll(() => document.getElementById(`${buttonId}-loading-announcement`)?.textContent) + .toBe('Action loading') }) - it('should be described by loading announcement AND whatever is passed to aria-describedby', () => { + it('should be described by loading announcement AND whatever is passed to aria-describedby', async () => { const buttonDescriptionId = 'button-description' const buttonId = 'loading-button' - const container = render( + + render( content , ) - const buttonNode = container.getByRole('button') - fireEvent.click(buttonNode) + const buttonNode = page.getByRole('button') + + await userEvent.click(buttonNode) - const buttonDescribedBy = container.getByRole('button').getAttribute('aria-describedby') + const buttonDescribedBy = page.getByRole('button').element().getAttribute('aria-describedby') const loadingAnnouncementId = `${buttonId}-loading-announcement` expect(buttonDescribedBy).toContain(loadingAnnouncementId) @@ -263,31 +281,29 @@ describe('Button', () => { }) it('should only set aria-disabled to "true" when the button is in a loading state', async () => { - const container = render( + render( content , ) - const buttonNode = container.getByRole('button') - expect(buttonNode.getAttribute('aria-disabled')).not.toBe('true') + const buttonNode = page.getByRole('button') - fireEvent.click(buttonNode) + expect(buttonNode.element().getAttribute('aria-disabled')).not.toBe('true') - // Wait for the loading state to be set - await new Promise(resolve => setTimeout(resolve, 10)) + await userEvent.click(buttonNode) - expect(buttonNode.getAttribute('aria-disabled')).toBe('true') + await expect.element(buttonNode).toHaveAttribute('aria-disabled', 'true') }) - it('allows the consumer to override `aria-disabled`', () => { - const container = render() + it('allows the consumer to override `aria-disabled`', async () => { + render() - expect(container.getByRole('button')).toHaveAttribute('aria-disabled', 'true') + await expect.element(page.getByRole('button')).toHaveAttribute('aria-disabled', 'true') }) describe('accessible loading disabled styles', () => { - const visualStyles = (element: HTMLElement) => { + const visualStyles = (element: Element) => { const {backgroundColor, borderColor, color, cursor} = getComputedStyle(element) return {backgroundColor, borderColor, color, cursor} } @@ -295,7 +311,7 @@ describe('Button', () => { const renderWithLoadingDisabledStyles = (children: React.ReactNode) => render({children}) - it('applies disabled styles to a loading primary button without using native disabled', () => { + it('applies disabled styles to a loading primary button without using native disabled', async () => { renderWithLoadingDisabledStyles( <> , ) - fireEvent.click(screen.getByRole('button', {name: 'Submit'})) + fireEvent.click( + page + .getByRole('button', { + name: 'Submit', + exact: true, + }) + .element(), + ) expect(onClick).not.toHaveBeenCalled() }) - it('remains focusable while loading', () => { + it('remains focusable while loading', async () => { render( , ) - const button = screen.getByRole('button', {name: 'Submit'}) - button.focus() - expect(button).toHaveFocus() + const button = page.getByRole('button', { + name: 'Submit', + exact: true, + }) + button.element().focus() + expect(button.element()).toHaveFocus() }) it('still applies disabled styles for aria-disabled without loading', () => { @@ -396,8 +445,24 @@ describe('Button', () => { , ) - expect(visualStyles(screen.getByRole('button', {name: 'Aria disabled'}))).toEqual( - visualStyles(screen.getByRole('button', {name: 'Disabled'})), + expect( + visualStyles( + page + .getByRole('button', { + name: 'Aria disabled', + exact: true, + }) + .element(), + ), + ).toEqual( + visualStyles( + page + .getByRole('button', { + name: 'Disabled', + exact: true, + }) + .element(), + ), ) }) @@ -413,160 +478,197 @@ describe('Button', () => { , ) - expect(visualStyles(screen.getByRole('button', {name: 'Inactive'}))).not.toEqual( - visualStyles(screen.getByRole('button', {name: 'Disabled'})), + expect( + visualStyles( + page + .getByRole('button', { + name: 'Inactive', + exact: true, + }) + .element(), + ), + ).not.toEqual( + visualStyles( + page + .getByRole('button', { + name: 'Disabled', + exact: true, + }) + .element(), + ), ) }) }) - it('should preserve the accessible button name when the button is in a loading state', () => { - const container = render() + it('should preserve the accessible button name when the button is in a loading state', async () => { + render() - expect(container.getByRole('button')).toHaveAccessibleName('content') + await expect.element(page.getByRole('button')).toHaveAccessibleName('content') }) - it('should apply inline-flex display to loading wrapper when variant is link', () => { + it('should apply inline-flex display to loading wrapper when variant is link', async () => { const {container} = render( , ) - const wrapper = container.querySelector('[data-loading-wrapper]') - expect(wrapper).toBeInTheDocument() - expect(wrapper).toHaveClass(classes.ConditionalWrapperLink) + const wrapper = container.querySelector('[data-loading-wrapper]') + await expect.element(wrapper).toBeInTheDocument() + await expect.element(wrapper).toHaveClass(classes.ConditionalWrapperLink) }) - it('should render tooltip on an icon button when unsafeDisableTooltip prop is passed as false', () => { - const {getByRole, getByText} = render() - const triggerEL = getByRole('button') - const tooltipEl = getByText('Heart') - expect(triggerEL).toHaveAttribute('aria-labelledby', tooltipEl.id) + it('should render tooltip on an icon button when unsafeDisableTooltip prop is passed as false', async () => { + render() + const triggerEL = page.getByRole('button') + const tooltipEl = page.getByText('Heart', { + exact: true, + }) + await expect.element(triggerEL).toHaveAttribute('aria-labelledby', tooltipEl.element().id) }) - it('should render description type tooltip on an icon button when unsafeDisableTooltip prop is passed as false', () => { - const {getByRole, getByText} = render( - , - ) - const triggerEL = getByRole('button') - expect(triggerEL).toHaveAttribute('aria-label', 'Heart') - const tooltipEl = getByText('Love is all around') - expect(triggerEL.getAttribute('aria-describedby')).toContain(tooltipEl.id) - }) - it('should render tooltip on an icon button by default', () => { - const {getByRole, getByText} = render() - const triggerEl = getByRole('button') - const tooltipEl = getByText('Heart') - expect(triggerEl).toHaveAttribute('aria-labelledby', tooltipEl.id) - expect(triggerEl).not.toHaveAttribute('aria-label') - }) - it('should render aria-keyshorts on an icon button when keyshortcuts prop is passed', () => { - const {getByRole} = render() - const triggerEl = getByRole('button') - expect(triggerEl).toHaveAttribute('aria-keyshortcuts', 'Command+H') - }) - it('should append the keyshortcuts to the tooltip text that labels the icon button when keyshortcuts prop is passed', () => { - const {getByRole} = render() - const triggerEl = getByRole('button', {name: 'Heart (command h)'}) - expect(triggerEl).toBeInTheDocument() - }) - it('should render aria-keyshortcuts on an icon button when keyshortcuts prop is passed (Description Type)', () => { - const {getByRole} = render( - , - ) - const triggerEl = getByRole('button') - expect(triggerEl).toHaveAttribute('aria-keyshortcuts', 'Command+H') + it('should render description type tooltip on an icon button when unsafeDisableTooltip prop is passed as false', async () => { + render() + + const triggerEL = page.getByRole('button') + await expect.element(triggerEL).toHaveAttribute('aria-label', 'Heart') + const tooltipEl = page.getByText('Love is all around', { + exact: true, + }) + expect(triggerEL.element().getAttribute('aria-describedby')).toContain(tooltipEl.element().id) }) - it('should append the keyshortcuts to the tooltip text that describes the icon button when keyshortcuts prop is passed (Description Type)', () => { - const {getByRole} = render( - , - ) - const triggerEl = getByRole('button', {name: 'Heart'}) - expect(triggerEl).toHaveAccessibleDescription('Love is all around (command h)') + it('should render tooltip on an icon button by default', async () => { + render() + const triggerEl = page.getByRole('button') + const tooltipEl = page.getByText('Heart', { + exact: true, + }) + await expect.element(triggerEl).toHaveAttribute('aria-labelledby', tooltipEl.element().id) + await expect.element(triggerEl).not.toHaveAttribute('aria-label') + }) + it('should render aria-keyshorts on an icon button when keyshortcuts prop is passed', async () => { + render() + const triggerEl = page.getByRole('button') + await expect.element(triggerEl).toHaveAttribute('aria-keyshortcuts', 'Command+H') + }) + it('should append the keyshortcuts to the tooltip text that labels the icon button when keyshortcuts prop is passed', async () => { + render() + const triggerEl = page.getByRole('button', { + name: 'Heart (command h)', + exact: true, + }) + await expect.element(triggerEl).toBeInTheDocument() + }) + it('should render aria-keyshortcuts on an icon button when keyshortcuts prop is passed (Description Type)', async () => { + render() + + const triggerEl = page.getByRole('button') + await expect.element(triggerEl).toHaveAttribute('aria-keyshortcuts', 'Command+H') + }) + it('should append the keyshortcuts to the tooltip text that describes the icon button when keyshortcuts prop is passed (Description Type)', async () => { + render() + + const triggerEl = page.getByRole('button', { + name: 'Heart', + exact: true, + }) + await expect.element(triggerEl).toHaveAccessibleDescription('Love is all around (command h)') }) }) describe('data-component attributes', () => { describe('Button', () => { - it('should have data-component="Button" on the button element', () => { + it('should have data-component="Button" on the button element', async () => { const {container} = render() - expect(container.querySelector('[data-component="Button"]')).toBeInTheDocument() + await expect.element(container.querySelector('[data-component="Button"]')).toBeInTheDocument() }) - it('should have data-component="buttonContent" on the content wrapper', () => { + it('should have data-component="buttonContent" on the content wrapper', async () => { const {container} = render() - expect(container.querySelector('[data-component="buttonContent"]')).toBeInTheDocument() + await expect.element(container.querySelector('[data-component="buttonContent"]')).toBeInTheDocument() }) - it('should have data-component="text" on the text element', () => { + it('should have data-component="text" on the text element', async () => { const {container} = render() - expect(container.querySelector('[data-component="text"]')).toBeInTheDocument() + await expect.element(container.querySelector('[data-component="text"]')).toBeInTheDocument() }) - it('should have data-component="leadingVisual" on leading visual', () => { + it('should have data-component="leadingVisual" on leading visual', async () => { const {container} = render() - expect(container.querySelector('[data-component="leadingVisual"]')).toBeInTheDocument() + await expect.element(container.querySelector('[data-component="leadingVisual"]')).toBeInTheDocument() }) - it('should have data-component="trailingVisual" on trailing visual', () => { + it('should have data-component="trailingVisual" on trailing visual', async () => { const {container} = render() - expect(container.querySelector('[data-component="trailingVisual"]')).toBeInTheDocument() + await expect + .element(container.querySelector('[data-component="trailingVisual"]')) + .toBeInTheDocument() }) - it('should have data-component="ButtonCounter" on count element', () => { + it('should have data-component="ButtonCounter" on count element', async () => { const {container} = render() - expect(container.querySelector('[data-component="ButtonCounter"]')).toBeInTheDocument() + await expect.element(container.querySelector('[data-component="ButtonCounter"]')).toBeInTheDocument() }) - it('should have data-component="loadingSpinner" when loading without visuals', () => { + it('should have data-component="loadingSpinner" when loading without visuals', async () => { const {container} = render() - expect(container.querySelector('[data-component="loadingSpinner"]')).toBeInTheDocument() + await expect + .element(container.querySelector('[data-component="loadingSpinner"]')) + .toBeInTheDocument() }) }) describe('data-icon-only-counter', () => { - it('is set when count, leadingVisual, and no children are present', () => { + it('is set when count, leadingVisual, and no children are present', async () => { const {container} = render(, ) - expect(container.querySelector('[data-component="Button"]')).not.toHaveAttribute('data-icon-only-counter') + await expect + .element(container.querySelector('[data-component="Button"]')) + .not.toHaveAttribute('data-icon-only-counter') }) - it('is not set when leadingVisual is missing', () => { + it('is not set when leadingVisual is missing', async () => { const {container} = render( @@ -271,24 +274,24 @@ describe('Markup', () => { ) // Focus button - const button = screen.getByRole('button', {name: /Focusable element/}) + const button = page.getByRole('button', {name: /Focusable element/}) act(() => { - button.focus() + button.element().focus() }) - expect(button).toHaveFocus() + expect(button.element()).toHaveFocus() // Move focus to tree - const item1 = screen.getByRole('treeitem', {name: /Item 1/}) + const item1 = page.getByRole('treeitem', {name: /Item 1/}) act(() => { - item1.focus() + item1.element().focus() }) // Focus should be on current treeitem - const item2 = screen.getByRole('treeitem', {name: /Item 2/}) - expect(item2).toHaveFocus() + const item2 = page.getByRole('treeitem', {name: /Item 2/}) + expect(item2.element()).toHaveFocus() }) - it('should toggle when receiving focus from chevron click', () => { + it('should toggle when receiving focus from chevron click', async () => { render(
@@ -310,28 +313,23 @@ describe('Markup', () => { ) // Focus button - const button = screen.getByRole('button', {name: /Focusable element/}) + const button = page.getByRole('button', {name: /Focusable element/}) act(() => { - button.focus() + button.element().focus() }) - expect(button).toHaveFocus() + expect(button.element()).toHaveFocus() // Move focus to tree - const item1 = screen.getByRole('treeitem', {name: /Item 1/}) - const toggle = item1.querySelector('.PRIVATE_TreeView-item-toggle') as HTMLElement - act(() => { - // Note: calling `.click()` directly here since the userEvent.click() - // warns about it not being a known focusable element. This should be a - // valid pattern based on our TreeView guidelines. - toggle.click() - }) + const item1 = page.getByRole('treeitem', {name: /Item 1/}) + const toggle = item1.element().querySelector('.PRIVATE_TreeView-item-toggle') as HTMLElement + await userEvent.click(toggle) // Focus should be on current treeitem - const subItem1 = screen.getByRole('treeitem', {name: /SubItem 1/}) - expect(subItem1).toBeInTheDocument() + const subItem1 = page.getByRole('treeitem', {name: /SubItem 1/}) + await expect.element(subItem1).toBeInTheDocument() }) - it("should move focus to first treeitem when focusing back in after clicking on a treeitem's secondary action", () => { + it("should move focus to first treeitem when focusing back in after clicking on a treeitem's secondary action", async () => { render(
@@ -349,28 +347,28 @@ describe('Markup', () => { ) // Click on treeitem's secondary action - const item2Button = screen.getByText(/Link in Item 2/i) + const item2Button = page.getByText(/Link in Item 2/i) act(() => { - item2Button.focus() + item2Button.element().focus() }) - expect(item2Button).toHaveFocus() + expect(item2Button.element()).toHaveFocus() // Move focus to button outside of TreeView - const outerButton = screen.getByRole('button', {name: /Focusable element/}) + const outerButton = page.getByRole('button', {name: /Focusable element/}) act(() => { - outerButton.focus() + outerButton.element().focus() }) - expect(outerButton).toHaveFocus() + expect(outerButton.element()).toHaveFocus() // Move focus into TreeView. Focus should be on first treeitem - const item1 = screen.getByRole('treeitem', {name: /Item 1/}) + const item1 = page.getByRole('treeitem', {name: /Item 1/}) act(() => { - item1.focus() + item1.element().focus() }) - expect(item1).toHaveFocus() + expect(item1.element()).toHaveFocus() }) - it('keeps focus on a nested control when pressing ArrowDown', () => { + it('keeps focus on a nested control when pressing ArrowDown', async () => { render( Item 1 @@ -384,15 +382,20 @@ describe('Markup', () => { , ) - const item = screen.getByRole('treeitem', {name: 'Item 2'}) - const button = screen.getByText('Nested action') + const item = page.getByRole('treeitem', { + name: 'Item 2', + exact: true, + }) + const button = page.getByText('Nested action', { + exact: true, + }) - act(() => item.focus()) - act(() => button.focus()) - expect(button).toHaveFocus() + act(() => item.element().focus()) + act(() => button.element().focus()) + expect(button.element()).toHaveFocus() - expect(fireEvent.keyDown(button, {key: 'ArrowDown'})).toBe(false) - expect(button).toHaveFocus() + expect(fireEvent.keyDown(button.element(), {key: 'ArrowDown'})).toBe(false) + expect(button.element()).toHaveFocus() }) describe('as prop', () => { @@ -403,11 +406,11 @@ describe('Markup', () => { , ) - const item = screen.getByRole('treeitem', {name: /Item 1/}) - expect(item.tagName).toBe('LI') + const item = page.getByRole('treeitem', {name: /Item 1/}) + expect(item.element().tagName).toBe('LI') }) - it('renders as the element specified by `as`', () => { + it('renders as the element specified by `as`', async () => { render( @@ -416,12 +419,12 @@ describe('Markup', () => { , ) - const item = screen.getByRole('treeitem', {name: /Item 1/}) - expect(item.tagName).toBe('A') - expect(item).toHaveAttribute('href', '#item-1') + const item = page.getByRole('treeitem', {name: /Item 1/}) + expect(item.element().tagName).toBe('A') + await expect.element(item).toHaveAttribute('href', '#item-1') }) - it('wraps the polymorphic element in an `li role="none"` to keep markup valid', () => { + it('wraps the polymorphic element in an `li role="none"` to keep markup valid', async () => { render( @@ -430,13 +433,13 @@ describe('Markup', () => { , ) - const item = screen.getByRole('treeitem', {name: /Item 1/}) - const wrapper = item.parentElement + const item = page.getByRole('treeitem', {name: /Item 1/}) + const wrapper = item.element().parentElement expect(wrapper?.tagName).toBe('LI') - expect(wrapper).toHaveAttribute('role', 'none') + await expect.element(wrapper).toHaveAttribute('role', 'none') }) - it('does not add an extra `li role="none"` wrapper when `as="li"`', () => { + it('does not add an extra `li role="none"` wrapper when `as="li"`', async () => { render( @@ -445,14 +448,14 @@ describe('Markup', () => { , ) - const item = screen.getByRole('treeitem', {name: /Item 1/}) - expect(item.tagName).toBe('LI') + const item = page.getByRole('treeitem', {name: /Item 1/}) + expect(item.element().tagName).toBe('LI') // The treeitem should be a direct child of the tree (`
    `), not wrapped // in an outer `
  • `. - expect(item.parentElement).toHaveAttribute('role', 'tree') + await expect.element(item.element().parentElement).toHaveAttribute('role', 'tree') }) - it('supports polymorphic Item with custom component via `as`', () => { + it('supports polymorphic Item with custom component via `as`', async () => { const CustomLink = React.forwardRef< HTMLAnchorElement, React.AnchorHTMLAttributes & {custom: boolean} @@ -471,13 +474,13 @@ describe('Markup', () => { , ) - const item = screen.getByRole('treeitem', {name: /Docs/}) - expect(item.tagName).toBe('A') - expect(item).toHaveAttribute('href', '#docs') - expect(item).toHaveAttribute('data-custom-link', 'true') + const item = page.getByRole('treeitem', {name: /Docs/}) + expect(item.element().tagName).toBe('A') + await expect.element(item).toHaveAttribute('href', '#docs') + await expect.element(item).toHaveAttribute('data-custom-link', 'true') }) - it('preserves treeitem role, tabIndex, and aria attributes when `as` is provided', () => { + it('preserves treeitem role, tabIndex, and aria attributes when `as` is provided', async () => { render( @@ -486,11 +489,11 @@ describe('Markup', () => { , ) - const item = screen.getByRole('treeitem', {name: /Item 1/}) - expect(item).toHaveAttribute('role', 'treeitem') - expect(item).toHaveAttribute('tabindex', '0') - expect(item).toHaveAttribute('aria-current', 'true') - expect(item).toHaveAttribute('aria-level', '1') + const item = page.getByRole('treeitem', {name: /Item 1/}) + await expect.element(item).toHaveAttribute('role', 'treeitem') + await expect.element(item).toHaveAttribute('tabindex', '0') + await expect.element(item).toHaveAttribute('aria-current', 'true') + await expect.element(item).toHaveAttribute('aria-level', '1') }) it('forwards ref to the element specified by `as`', () => { @@ -507,7 +510,7 @@ describe('Markup', () => { expect(ref.current?.tagName).toBe('A') }) - it('calls onSelect when the polymorphic element is clicked', () => { + it('calls onSelect when the polymorphic element is clicked', async () => { const onSelect = vi.fn() render( @@ -517,8 +520,8 @@ describe('Markup', () => { , ) - const item = screen.getByRole('treeitem', {name: /Item 1/}) - fireEvent.click(item) + const item = page.getByRole('treeitem', {name: /Item 1/}) + await userEvent.click(item) expect(onSelect).toHaveBeenCalledTimes(1) }) }) @@ -526,7 +529,7 @@ describe('Markup', () => { describe('Keyboard interactions', () => { describe('ArrowDown', () => { - it('moves focus to the next visible treeitem', () => { + it('moves focus to the next visible treeitem', async () => { render( @@ -546,44 +549,56 @@ describe('Keyboard interactions', () => { , ) - const item1 = screen.getByRole('treeitem', {name: 'Item 1'}) - const item11 = screen.getByRole('treeitem', {name: 'Item 1.1'}) - const item2 = screen.getByRole('treeitem', {name: 'Item 2'}) - const item3 = screen.getByRole('treeitem', {name: 'Item 3'}) + const item1 = page.getByRole('treeitem', { + name: 'Item 1', + exact: true, + }) + const item11 = page.getByRole('treeitem', { + name: 'Item 1.1', + exact: true, + }) + const item2 = page.getByRole('treeitem', { + name: 'Item 2', + exact: true, + }) + const item3 = page.getByRole('treeitem', { + name: 'Item 3', + exact: true, + }) act(() => { // Focus first item - item1.focus() + item1.element().focus() }) // Press ↓ - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') // item 1.1 should be focused - expect(item11).toHaveFocus() + await expect.element(item11).toHaveFocus() // Press ↓ - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') // item 2 should be focused - expect(item2).toHaveFocus() + await expect.element(item2).toHaveFocus() // Press ↓ - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') // item 3 should have focus (skips item 2.1 and item 2.2 because they are hidden) - expect(item3).toHaveFocus() + await expect.element(item3).toHaveFocus() // Press ↓ - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') // focus should not change (item 3 is the last visible treeitem) - expect(item3).toHaveFocus() + await expect.element(item3).toHaveFocus() }) }) describe('ArrowUp', () => { - it('moves focus to the previous visible treeitem', () => { + it('moves focus to the previous visible treeitem', async () => { render( @@ -603,53 +618,65 @@ describe('Keyboard interactions', () => { , ) - const item1 = screen.getByRole('treeitem', {name: 'Item 1'}) - const item11 = screen.getByRole('treeitem', {name: 'Item 1.1'}) - const item2 = screen.getByRole('treeitem', {name: 'Item 2'}) - const item3 = screen.getByRole('treeitem', {name: 'Item 3'}) + const item1 = page.getByRole('treeitem', { + name: 'Item 1', + exact: true, + }) + const item11 = page.getByRole('treeitem', { + name: 'Item 1.1', + exact: true, + }) + const item2 = page.getByRole('treeitem', { + name: 'Item 2', + exact: true, + }) + const item3 = page.getByRole('treeitem', { + name: 'Item 3', + exact: true, + }) act(() => { // Focus first item - item1.focus() + item1.element().focus() }) // Press ↓ 4 times to move aria-activedescendant to item 3 - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') // item 3 should be focused - expect(item3).toHaveFocus() + await expect.element(item3).toHaveFocus() // Press ↑ - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + await userEvent.keyboard('{ArrowUp}') // Item 2 should have focus (skips item 2.1 and item 2.2 because they are hidden) - expect(item2).toHaveFocus() + await expect.element(item2).toHaveFocus() // Press ↑ - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + await userEvent.keyboard('{ArrowUp}') // Item 1.1 should be focused - expect(item11).toHaveFocus() + await expect.element(item11).toHaveFocus() // Press ↑ - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + await userEvent.keyboard('{ArrowUp}') // Item 1 should be focused - expect(item1).toHaveFocus() + await expect.element(item1).toHaveFocus() // Press ↑ - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + await userEvent.keyboard('{ArrowUp}') // Focus should not change (item 1 is the first visible treeitem) - expect(item1).toHaveFocus() + await expect.element(item1).toHaveFocus() }) }) describe('ArrowLeft', () => { - it('collapses an expanded item', () => { + it('collapses an expanded item', async () => { render( @@ -661,36 +688,39 @@ describe('Keyboard interactions', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) - let subtree = screen.queryByRole('group') + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) + let subtree = page.getByRole('group') // aria-expanded should be true - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') // Subtree should be visible - expect(subtree).toBeVisible() + await expect.element(subtree).toBeVisible() act(() => { // Focus first item - parentItem.focus() + parentItem.element().focus() }) // Press ← - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + await userEvent.keyboard('{ArrowLeft}') // aria-expanded should now be false - expect(parentItem).toHaveAttribute('aria-expanded', 'false') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'false') // Parent item should still be focused - expect(parentItem).toHaveFocus() + await expect.element(parentItem).toHaveFocus() - subtree = screen.queryByRole('group') + subtree = page.getByRole('group') // Subtree should now be hidden - expect(subtree).toBeNull() + await expect.element(subtree).not.toBeInTheDocument() }) - it('does nothing on a root-level collapsed item', () => { + it('does nothing on a root-level collapsed item', async () => { render( @@ -702,48 +732,54 @@ describe('Keyboard interactions', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) // aria-expanded should be false by default - expect(parentItem).toHaveAttribute('aria-expanded', 'false') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'false') act(() => { // Focus first item - parentItem.focus() + parentItem.element().focus() }) // Press ← - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + await userEvent.keyboard('{ArrowLeft}') // aria-expanded should still be false - expect(parentItem).toHaveAttribute('aria-expanded', 'false') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'false') // Focus should not change - expect(parentItem).toHaveFocus() + await expect.element(parentItem).toHaveFocus() }) - it('does nothing on a root-level end item', () => { + it('does nothing on a root-level end item', async () => { render( Item , ) - const item = screen.getByRole('treeitem', {name: 'Item'}) + const item = page.getByRole('treeitem', { + name: 'Item', + exact: true, + }) act(() => { // Focus first item - item.focus() + item.element().focus() }) // Press ← - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + await userEvent.keyboard('{ArrowLeft}') // Focus should not change - expect(item).toHaveFocus() + await expect.element(item).toHaveFocus() }) - it('moves focus to parent of end item', () => { + it('moves focus to parent of end item', async () => { render( @@ -756,29 +792,35 @@ describe('Keyboard interactions', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) - const child2 = screen.getByRole('treeitem', {name: 'Child 2'}) + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) + const child2 = page.getByRole('treeitem', { + name: 'Child 2', + exact: true, + }) act(() => { // Focus fist item - parentItem.focus() + parentItem.element().focus() }) // Press ↓ 2 times to move focus to child 2 - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') // Child 2 should be focused - expect(child2).toHaveFocus() + await expect.element(child2).toHaveFocus() // Press ← - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + await userEvent.keyboard('{ArrowLeft}') // Parent item should be focused - expect(parentItem).toHaveFocus() + await expect.element(parentItem).toHaveFocus() }) - it('moves focus to parent of collapsed item', () => { + it('moves focus to parent of collapsed item', async () => { render( @@ -796,31 +838,37 @@ describe('Keyboard interactions', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) - const nestedParentItem = screen.getByRole('treeitem', {name: 'Nested parent'}) + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) + const nestedParentItem = page.getByRole('treeitem', { + name: 'Nested parent', + exact: true, + }) act(() => { // Focus first item - parentItem.focus() + parentItem.element().focus() }) // Press ↓ 2 times to move focus to nested parent - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') // Nested parent item should be focused - expect(nestedParentItem).toHaveFocus() + await expect.element(nestedParentItem).toHaveFocus() // Press ← - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + await userEvent.keyboard('{ArrowLeft}') // Parent item should be focused - expect(parentItem).toHaveFocus() + await expect.element(parentItem).toHaveFocus() }) }) describe('ArrowRight', () => { - it('expands a collapsed item', () => { + it('expands a collapsed item', async () => { render( @@ -832,32 +880,35 @@ describe('Keyboard interactions', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) // aria-expanded should be false by default - expect(parentItem).toHaveAttribute('aria-expanded', 'false') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'false') act(() => { // Focus first item - parentItem.focus() + parentItem.element().focus() }) // Press → - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowRight'}) + await userEvent.keyboard('{ArrowRight}') // aria-expanded should now be true - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') // Parent item should still be focused - expect(parentItem).toHaveFocus() + await expect.element(parentItem).toHaveFocus() - const subtree = screen.getByRole('group') + const subtree = page.getByRole('group') // Subtree should now be visible - expect(subtree).toBeVisible() + await expect.element(subtree).toBeVisible() }) - it('moves focus to first child of an expanded item', () => { + it('moves focus to first child of an expanded item', async () => { render( @@ -869,30 +920,34 @@ describe('Keyboard interactions', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) // aria-expanded should be true - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') act(() => { - parentItem.focus() + parentItem.element().focus() }) - expect(parentItem).toHaveFocus() + await expect.element(parentItem).toHaveFocus() - act(() => { - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowRight'}) - }) + await userEvent.keyboard('{ArrowRight}') - const childItem = screen.getByRole('treeitem', {name: 'Child'}) + const childItem = page.getByRole('treeitem', { + name: 'Child', + exact: true, + }) // Child item should be focused - expect(childItem).toHaveFocus() + await expect.element(childItem).toHaveFocus() // aria-expanded should still be true - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') }) - it('does nothing on an end item', () => { + it('does nothing on an end item', async () => { render( @@ -905,30 +960,36 @@ describe('Keyboard interactions', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) - const child1 = screen.getByRole('treeitem', {name: 'Child 1'}) + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) + const child1 = page.getByRole('treeitem', { + name: 'Child 1', + exact: true, + }) act(() => { // Focus first item - parentItem.focus() + parentItem.element().focus() }) // Press ↓ to move focus to child 1 - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') // Child 1 should be focused - expect(child1).toHaveFocus() + await expect.element(child1).toHaveFocus() // Press → - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowRight'}) + await userEvent.keyboard('{ArrowRight}') // Focus should not change - expect(child1).toHaveFocus() + await expect.element(child1).toHaveFocus() }) }) describe('Backspace', () => { - it('should move focus to the parent item', () => { + it('should move focus to the parent item', async () => { render( @@ -940,20 +1001,25 @@ describe('Keyboard interactions', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) - const child = screen.getByRole('treeitem', {name: 'Child'}) - + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) + const child = page.getByRole('treeitem', { + name: 'Child', + exact: true, + }) act(() => { - child.focus() + child.element().focus() }) // Press Backspace - fireEvent.keyDown(document.activeElement || document.body, {key: 'Backspace'}) + await userEvent.keyboard('{Backspace}') - expect(parentItem).toHaveFocus() + await expect.element(parentItem).toHaveFocus() }) - it('should not collapse an expanded item', () => { + it('should not collapse an expanded item', async () => { render( @@ -965,33 +1031,36 @@ describe('Keyboard interactions', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) - const subtree = screen.queryByRole('group') + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) + const subtree = page.getByRole('group') // aria-expanded should be true - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') // Subtree should be visible - expect(subtree).toBeVisible() + await expect.element(subtree).toBeVisible() act(() => { // Focus first item - parentItem.focus() + parentItem.element().focus() }) // Press Backspace - fireEvent.keyDown(document.activeElement || document.body, {key: 'Backspace'}) + await userEvent.keyboard('{Backspace}') // aria-expanded should stay set as true - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') // Parent item should still be focused - expect(parentItem).toHaveFocus() + await expect.element(parentItem).toHaveFocus() }) }) describe('Home', () => { - it('moves focus to first visible item', () => { + it('moves focus to first visible item', async () => { render( @@ -1015,30 +1084,36 @@ describe('Keyboard interactions', () => { , ) - const parent1 = screen.getByRole('treeitem', {name: 'Parent 1'}) - const parent3 = screen.getByRole('treeitem', {name: 'Parent 2'}) + const parent1 = page.getByRole('treeitem', { + name: 'Parent 1', + exact: true, + }) + const parent3 = page.getByRole('treeitem', { + name: 'Parent 2', + exact: true, + }) act(() => { // Focus first item - parent1.focus() + parent1.element().focus() }) // Press ↓ 2 times to move focus to parent 3 - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') // Parent 3 should be focused - expect(parent3).toHaveFocus() + await expect.element(parent3).toHaveFocus() // Press Home - fireEvent.keyDown(document.activeElement || document.body, {key: 'Home'}) + await userEvent.keyboard('{Home}') // Parent 1 should be focused - expect(parent1).toHaveFocus() + await expect.element(parent1).toHaveFocus() }) }) describe('End', () => { - it('moves focus to last visible item', () => { + it('moves focus to last visible item', async () => { render( @@ -1062,35 +1137,44 @@ describe('Keyboard interactions', () => { , ) - const parent1 = screen.getByRole('treeitem', {name: 'Parent 1'}) - const parent3 = screen.getByRole('treeitem', {name: 'Parent 3'}) + const parent1 = page.getByRole('treeitem', { + name: 'Parent 1', + exact: true, + }) + const parent3 = page.getByRole('treeitem', { + name: 'Parent 3', + exact: true, + }) act(() => { // Focus first item - parent1.focus() + parent1.element().focus() }) // Press End - fireEvent.keyDown(document.activeElement || document.body, {key: 'End'}) + await userEvent.keyboard('{End}') // Parent 3 should be focused - expect(parent3).toHaveFocus() + await expect.element(parent3).toHaveFocus() // Press → to expand parent 3 - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowRight'}) + await userEvent.keyboard('{ArrowRight}') // Press End - fireEvent.keyDown(document.activeElement || document.body, {key: 'End'}) + await userEvent.keyboard('{End}') - const child3 = screen.getByRole('treeitem', {name: 'Child 3'}) + const child3 = page.getByRole('treeitem', { + name: 'Child 3', + exact: true, + }) // Child 3 should be focused - expect(child3).toHaveFocus() + await expect.element(child3).toHaveFocus() }) }) describe('Enter', () => { - it('calls onSelect function if provided and checks if the item has been selected', () => { + it('calls onSelect function if provided and checks if the item has been selected', async () => { const onSelect = vi.fn() render( @@ -1120,15 +1204,18 @@ describe('Keyboard interactions', () => { , ) - const itemChild = screen.getByRole('treeitem', {name: 'Child2'}) + const itemChild = page.getByRole('treeitem', { + name: 'Child2', + exact: true, + }) act(() => { // Focus first item - itemChild.focus() + itemChild.element().focus() }) // Press Enter - fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + await userEvent.keyboard('{Enter}') // onSelect should have been called expect(onSelect).toHaveBeenCalledTimes(1) @@ -1142,7 +1229,7 @@ describe('Keyboard interactions', () => { expect(onSelect).toHaveBeenCalledTimes(1) }) - it('toggles expanded state if no onSelect function is provided', () => { + it('toggles expanded state if no onSelect function is provided', async () => { render( @@ -1155,38 +1242,41 @@ describe('Keyboard interactions', () => { , ) - const parent = screen.getByRole('treeitem', {name: 'Parent'}) + const parent = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) act(() => { // Focus first item - parent.focus() + parent.element().focus() }) // aria-expanded should be false - expect(parent).toHaveAttribute('aria-expanded', 'false') + await expect.element(parent).toHaveAttribute('aria-expanded', 'false') // Press Enter - fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + await userEvent.keyboard('{Enter}') // aria-expanded should now be true - expect(parent).toHaveAttribute('aria-expanded', 'true') + await expect.element(parent).toHaveAttribute('aria-expanded', 'true') // Subtree should be visible - expect(screen.queryByRole('group')).toBeVisible() + await expect.element(page.getByRole('group')).toBeVisible() // Press Enter - fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + await userEvent.keyboard('{Enter}') // aria-expanded should now be false - expect(parent).toHaveAttribute('aria-expanded', 'false') + await expect.element(parent).toHaveAttribute('aria-expanded', 'false') // Subtree should no longer be visible - expect(screen.queryByRole('group')).not.toBeInTheDocument() + await expect.element(page.getByRole('group')).not.toBeInTheDocument() }) }) describe('Space', () => { - it('calls onSelect function if provided and checks if the item has been selected', () => { + it('calls onSelect function if provided and checks if the item has been selected', async () => { const onSelect = vi.fn() render( @@ -1216,15 +1306,18 @@ describe('Keyboard interactions', () => { , ) - const itemChild = screen.getByRole('treeitem', {name: 'Child2'}) + const itemChild = page.getByRole('treeitem', { + name: 'Child2', + exact: true, + }) act(() => { // Focus first item - itemChild.focus() + itemChild.element().focus() }) // Press Enter - fireEvent.keyDown(document.activeElement || document.body, {key: ' '}) + await userEvent.keyboard(' ') // onSelect should have been called expect(onSelect).toHaveBeenCalledTimes(1) @@ -1238,7 +1331,7 @@ describe('Keyboard interactions', () => { expect(onSelect).toHaveBeenCalledTimes(1) }) - it('toggles expanded state if no onSelect function is provided', () => { + it('toggles expanded state if no onSelect function is provided', async () => { render( @@ -1251,38 +1344,41 @@ describe('Keyboard interactions', () => { , ) - const parent = screen.getByRole('treeitem', {name: 'Parent'}) + const parent = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) act(() => { // Focus first item - parent.focus() + parent.element().focus() }) // aria-expanded should be false - expect(parent).toHaveAttribute('aria-expanded', 'false') + await expect.element(parent).toHaveAttribute('aria-expanded', 'false') // Press Enter - fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + await userEvent.keyboard('{Enter}') // aria-expanded should now be true - expect(parent).toHaveAttribute('aria-expanded', 'true') + await expect.element(parent).toHaveAttribute('aria-expanded', 'true') // Subtree should be visible - expect(screen.queryByRole('group')).toBeVisible() + await expect.element(page.getByRole('group')).toBeVisible() // Press Enter - fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + await userEvent.keyboard('{Enter}') // aria-expanded should now be false - expect(parent).toHaveAttribute('aria-expanded', 'false') + await expect.element(parent).toHaveAttribute('aria-expanded', 'false') // Subtree should no longer be visible - expect(screen.queryByRole('group')).not.toBeInTheDocument() + await expect.element(page.getByRole('group')).not.toBeInTheDocument() }) }) describe('Typeahead', () => { - it('moves focus to the next item that matches the typed character', () => { + it('moves focus to the next item that matches the typed character', async () => { render( @@ -1297,25 +1393,31 @@ describe('Keyboard interactions', () => { , ) - const apple = screen.getByRole('treeitem', {name: 'Apple'}) - const cherry = screen.getByRole('treeitem', {name: 'Cherry'}) + const apple = page.getByRole('treeitem', { + name: 'Apple', + exact: true, + }) + const cherry = page.getByRole('treeitem', { + name: 'Cherry', + exact: true, + }) act(() => { // Focus first item - apple.focus() + apple.element().focus() }) // Press C - fireEvent.keyDown(document.activeElement || document.body, {key: 'c'}) + await userEvent.keyboard('c') // Cherry should be focused - expect(cherry).toHaveFocus() + await expect.element(cherry).toHaveFocus() // Notice that the focus is not set to cantalope because // it is a child of apple and apple is collapsed. }) - it('does nothing if no items match the typed character', () => { + it('does nothing if no items match the typed character', async () => { render( Apple @@ -1325,24 +1427,27 @@ describe('Keyboard interactions', () => { , ) - const apple = screen.getByRole('treeitem', {name: 'Apple'}) + const apple = page.getByRole('treeitem', { + name: 'Apple', + exact: true, + }) act(() => { // Focus first item - apple.focus() + apple.element().focus() }) // Apple should be focused - expect(apple).toHaveFocus() + await expect.element(apple).toHaveFocus() // Press Z - fireEvent.keyDown(document.activeElement || document.body, {key: 'z'}) + await userEvent.keyboard('z') // Apple should still be focused - expect(apple).toHaveFocus() + await expect.element(apple).toHaveFocus() }) - it('supports multiple typed characters', () => { + it('supports multiple typed characters', async () => { render( Apple @@ -1353,24 +1458,30 @@ describe('Keyboard interactions', () => { , ) - const apple = screen.getByRole('treeitem', {name: 'Apple'}) - const cantalope = screen.getByRole('treeitem', {name: 'Cantalope 1'}) + const apple = page.getByRole('treeitem', { + name: 'Apple', + exact: true, + }) + const cantalope = page.getByRole('treeitem', { + name: 'Cantalope 1', + exact: true, + }) act(() => { // Focus first item - apple.focus() + apple.element().focus() }) // Press C + A + N - fireEvent.keyDown(document.activeElement || document.body, {key: 'c'}) - fireEvent.keyDown(document.activeElement || document.body, {key: 'a'}) - fireEvent.keyDown(document.activeElement || document.body, {key: 'n'}) + await userEvent.keyboard('c') + await userEvent.keyboard('a') + await userEvent.keyboard('n') // Cantalope should be focused - expect(cantalope).toHaveFocus() + await expect.element(cantalope).toHaveFocus() }) - it('prioritizes items following the current aria-activedescendant', () => { + it('prioritizes items following the current aria-activedescendant', async () => { render( Cucumber @@ -1381,29 +1492,38 @@ describe('Keyboard interactions', () => { , ) - const cucumber = screen.getByRole('treeitem', {name: 'Cucumber'}) - const cherry = screen.getByRole('treeitem', {name: 'Cherry'}) - const cantalope = screen.getByRole('treeitem', {name: 'Cantalope'}) + const cucumber = page.getByRole('treeitem', { + name: 'Cucumber', + exact: true, + }) + const cherry = page.getByRole('treeitem', { + name: 'Cherry', + exact: true, + }) + const cantalope = page.getByRole('treeitem', { + name: 'Cantalope', + exact: true, + }) act(() => { // Focus first item - cucumber.focus() + cucumber.element().focus() }) // Press ↓ to move focus to cherry - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') // Cherry should be focused - expect(cherry).toHaveFocus() + await expect.element(cherry).toHaveFocus() // Press C - fireEvent.keyDown(document.activeElement || document.body, {key: 'c'}) + await userEvent.keyboard('c') // Cantalope should be focused - expect(cantalope).toHaveFocus() + await expect.element(cantalope).toHaveFocus() }) - it('wraps around to the beginning if no items match after the current aria-activedescendant', () => { + it('wraps around to the beginning if no items match after the current aria-activedescendant', async () => { render( Cucumber @@ -1415,32 +1535,38 @@ describe('Keyboard interactions', () => { , ) - const cantalope = screen.getByRole('treeitem', {name: 'Cantalope'}) - const cucumber = screen.getByRole('treeitem', {name: 'Cucumber'}) + const cantalope = page.getByRole('treeitem', { + name: 'Cantalope', + exact: true, + }) + const cucumber = page.getByRole('treeitem', { + name: 'Cucumber', + exact: true, + }) act(() => { // Focus first item - cucumber.focus() + cucumber.element().focus() }) // Press ↓ 2 times to move focus to cantalope - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') // Cantalope should be focused - expect(cantalope).toHaveFocus() + await expect.element(cantalope).toHaveFocus() // Press C - fireEvent.keyDown(document.activeElement || document.body, {key: 'c'}) + await userEvent.keyboard('c') // Cucumber should be focused - expect(cucumber).toHaveFocus() + await expect.element(cucumber).toHaveFocus() }) }) }) describe('State', () => { - it('persists expanded state of nested items', () => { + it('persists expanded state of nested items', async () => { render( @@ -1457,43 +1583,56 @@ describe('State', () => { , ) - const item1 = screen.getByRole('treeitem', {name: 'Item 1'}) - const item2 = screen.getByRole('treeitem', {name: 'Item 2'}) + const item1 = page.getByRole('treeitem', { + name: 'Item 1', + exact: true, + }) + const item2 = page.getByRole('treeitem', { + name: 'Item 2', + exact: true, + }) // Item 2 should be collapsed by default - expect(item2).toHaveAttribute('aria-expanded', 'false') + await expect.element(item2).toHaveAttribute('aria-expanded', 'false') act(() => { // Focus item 2 - item2.focus() + item2.element().focus() }) // Press Enter to expand item 2 - fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + await userEvent.keyboard('{Enter}') // Item 2 should be expanded - expect(item2).toHaveAttribute('aria-expanded', 'true') + await expect.element(item2).toHaveAttribute('aria-expanded', 'true') // Press ↑ to move focus to item 1 - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowUp'}) + await userEvent.keyboard('{ArrowUp}') // Press Enter to collapse item 1 - fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + await userEvent.keyboard('{Enter}') // Item 1 should be collapsed - expect(item1).toHaveAttribute('aria-expanded', 'false') + await expect.element(item1).toHaveAttribute('aria-expanded', 'false') // Press Enter again to expand item 1 - fireEvent.keyDown(document.activeElement || document.body, {key: 'Enter'}) + await userEvent.keyboard('{Enter}') // Item 1 should be expanded - expect(item1).toHaveAttribute('aria-expanded', 'true') + await expect.element(item1).toHaveAttribute('aria-expanded', 'true') // Item 2 should still be expanded - expect(screen.getByRole('treeitem', {name: 'Item 2'})).toHaveAttribute('aria-expanded', 'true') + await expect + .element( + page.getByRole('treeitem', { + name: 'Item 2', + exact: true, + }), + ) + .toHaveAttribute('aria-expanded', 'true') }) - it('can be controlled', () => { + it('can be controlled', async () => { function TestTree() { const [expanded, setExpanded] = React.useState(true) return ( @@ -1510,24 +1649,32 @@ describe('State', () => { render() - const parent = screen.getByRole('treeitem', {name: 'Parent'}) - const child = screen.getByRole('treeitem', {name: 'Child'}) + const parent = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) + const child = page.getByRole('treeitem', { + name: 'Child', + exact: true, + }) + + const childElement = child.element() // Parent should be expanded - expect(parent).toHaveAttribute('aria-expanded', 'true') - expect(child).toBeVisible() + await expect.element(parent).toHaveAttribute('aria-expanded', 'true') + await expect.element(child).toBeVisible() act(() => { // Focus first item - parent.focus() + parent.element().focus() }) // Press ← to collapse the parent - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowLeft'}) + await userEvent.keyboard('{ArrowLeft}') // Parent should be collapsed - expect(parent).toHaveAttribute('aria-expanded', 'false') - expect(child).not.toBeVisible() + await expect.element(parent).toHaveAttribute('aria-expanded', 'false') + expect(childElement).not.toBeVisible() }) }) @@ -1574,29 +1721,20 @@ describe('Asynchronous loading', () => { render() - const doneButton = screen.getByRole('button', {name: 'Load'}) - - // Click load button to mimic async loading - act(() => { - fireEvent.click(doneButton) + const doneButton = page.getByRole('button', { + name: 'Load', + exact: true, }) + await userEvent.click(doneButton) + // Get live region after the first announcement creates it - const liveRegion = await waitFor(() => { - const region = getLiveRegion() - expect(region.getMessage('polite')).toBe('Parent content loading') - return region - }) + await expect.poll(() => getLiveRegion().getMessage('polite')).toBe('Parent content loading') + const liveRegion = getLiveRegion() - // Click done button to mimic the completion of async loading - act(() => { - fireEvent.click(doneButton) - }) + await userEvent.click(doneButton) - await waitFor(() => { - // Live region should be updated - expect(liveRegion.getMessage('polite')).toBe('Parent content loaded') - }) + await expect.poll(() => liveRegion.getMessage('polite')).toBe('Parent content loaded') }) it('moves focus from loading item to first child', async () => { @@ -1625,21 +1763,24 @@ describe('Asynchronous loading', () => { render() - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) - const loadingItem = screen.getByRole('treeitem', {name: 'Loading...'}) + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) + const loadingItem = page.getByRole('treeitem', { + name: 'Loading...', + exact: true, + }) act(() => { // Focus first item - parentItem.focus() + parentItem.element().focus() }) - // Press ↓ to move focus to loading item - act(() => { - fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'}) - }) + await userEvent.keyboard('{ArrowDown}') // Loading item should be focused - expect(loadingItem).toHaveFocus() + await expect.element(loadingItem).toHaveFocus() await act(async () => { await vi.advanceTimersByTimeAsync(400) @@ -1650,10 +1791,13 @@ describe('Asynchronous loading', () => { }) // Wait for async loading to complete - const firstChild = screen.getByRole('treeitem', {name: 'Child 1'}) + const firstChild = page.getByRole('treeitem', { + name: 'Child 1', + exact: true, + }) // First child should be focused - expect(firstChild).toHaveFocus() + expect(firstChild.element()).toHaveFocus() }) it('moves focus to parent item after closing error dialog', async () => { @@ -1684,34 +1828,33 @@ describe('Asynchronous loading', () => { } render() - const dialog = screen.getByRole('alertdialog') - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) + const dialog = page.getByRole('alertdialog') + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) // Parent item should not be focused - expect(parentItem).not.toHaveFocus() + await expect.element(parentItem).not.toHaveFocus() // Dialog should be visible - await vi.waitFor(() => { - expect(dialog).toBeVisible() - }) + await expect.element(dialog).toBeVisible() + const dialogElement = dialog.element() - // Press esc to close error dialog - act(() => { - fireEvent.keyDown(document, {key: 'Escape'}) - }) + await userEvent.keyboard('{Escape}') // Dialog should not be visible - expect(dialog).not.toBeVisible() + expect(dialogElement).not.toBeVisible() await act(async () => { await vi.runAllTimersAsync() }) // Parent item should be focused - expect(parentItem).toHaveFocus() + expect(parentItem.element()).toHaveFocus() }) - it('ignores arrow keys when error dialog is open', () => { + it('ignores arrow keys when error dialog is open', async () => { render( @@ -1724,29 +1867,29 @@ describe('Asynchronous loading', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) // Parent item should be expanded - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') - const retryButton = screen.getByRole('button', {name: 'Retry'}) - expect(retryButton).toHaveFocus() - - // Press ← - act(() => { - fireEvent.keyDown(retryButton, {key: 'ArrowLeft'}) + const retryButton = page.getByRole('button', { + name: 'Retry', + exact: true, }) + await expect.element(retryButton).toHaveFocus() + + await userEvent.keyboard('{ArrowLeft}') // Parent item should still be expanded - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') - // Press Backspace - act(() => { - fireEvent.keyDown(retryButton, {key: 'Backspace'}) - }) + await userEvent.keyboard('{Backspace}') // Parent item should still be expanded - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') }) it('should update `aria-expanded` if no content is loaded in', async () => { @@ -1786,20 +1929,25 @@ describe('Asynchronous loading', () => { render() - const treeitem = screen.getByLabelText('Item 1') - expect(treeitem).toHaveAttribute('aria-expanded', 'false') - act(() => { - fireEvent.click(screen.getByText('Item 1')) + const treeitem = page.getByRole('treeitem', { + name: 'Item 1', + exact: true, }) + await expect.element(treeitem).toHaveAttribute('aria-expanded', 'false') + await userEvent.click( + page.getByText('Item 1', { + exact: true, + }), + ) - expect(treeitem).toHaveAttribute('aria-expanded', 'true') + await expect.element(treeitem).toHaveAttribute('aria-expanded', 'true') await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) - expect(treeitem).toHaveAttribute('aria-expanded', 'true') - expect(screen.getByLabelText('No items found')).toBeInTheDocument() + expect(treeitem.element()).toHaveAttribute('aria-expanded', 'true') + expect(page.getByLabelText('No items found', {exact: true}).element()).toBeInTheDocument() }) it('should have `aria-expanded` when directory is empty', async () => { @@ -1824,21 +1972,50 @@ describe('Asynchronous loading', () => { , ) - const parentItem = screen.getByRole('treeitem', {name: 'Parent'}) + const parentItem = page.getByRole('treeitem', { + name: 'Parent', + exact: true, + }) // Parent item should be expanded - expect(parentItem).toHaveAttribute('aria-expanded', 'true') + await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') // Current child should not have `aria-expanded` - expect(screen.getByRole('treeitem', {name: 'child current'})).not.toHaveAttribute('aria-expanded') + await expect + .element( + page.getByRole('treeitem', { + name: 'child current', + exact: true, + }), + ) + .not.toHaveAttribute('aria-expanded') // Empty child should not have `aria-expanded` when closed - expect(screen.getByRole('treeitem', {name: 'empty child'})).not.toHaveAttribute('aria-expanded') + await expect + .element( + page.getByRole('treeitem', { + name: 'empty child', + exact: true, + }), + ) + .not.toHaveAttribute('aria-expanded') - fireEvent.click(screen.getByRole('treeitem', {name: 'empty child'})) + await userEvent.click( + page.getByRole('treeitem', { + name: 'empty child', + exact: true, + }), + ) // Empty child should have `aria-expanded` when opened - expect(screen.getByRole('treeitem', {name: 'empty child'})).toHaveAttribute('aria-expanded') + await expect + .element( + page.getByRole('treeitem', { + name: 'empty child', + exact: true, + }), + ) + .toHaveAttribute('aria-expanded') }) }) @@ -1868,10 +2045,10 @@ it('should render `TrailingAction`', async () => { , ) - expect(screen.getByRole('button', {hidden: true})).toHaveAttribute('aria-labelledby') + await expect.element(page.getByRole('button', {includeHidden: true})).toHaveAttribute('aria-labelledby') }) -it('should have keyboard shortcut command as part of accessible name when using `TrailingAction`', () => { +it('should have keyboard shortcut command as part of accessible name when using `TrailingAction`', async () => { render( , ) - expect(screen.getByRole('treeitem', {name: /for more actions\.$/})).toBeInTheDocument() + await expect.element(page.getByRole('treeitem', {name: /for more actions\.$/})).toBeInTheDocument() }) -it('should have keyboard shortcut command as part of accessible name when using `TrailingAction` and `aria-label`', () => { +it('should have keyboard shortcut command as part of accessible name when using `TrailingAction` and `aria-label`', async () => { render( , ) - expect(screen.getByRole('treeitem', {name: /for more actions\.$/})).toBeInTheDocument() + await expect.element(page.getByRole('treeitem', {name: /for more actions\.$/})).toBeInTheDocument() }) -it('should activate the dialog for trailing action when keyboard shortcut is used', () => { +it('should activate the dialog for trailing action when keyboard shortcut is used', async () => { render( , ) - const treeItem = screen.getByRole('treeitem', { + const treeItem = page.getByRole('treeitem', { name: /for more actions\.$/, }) act(() => { - treeItem.focus() + treeItem.element().focus() }) - expect(treeItem).toHaveFocus() + await expect.element(treeItem).toHaveFocus() - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument() - act(() => { - fireEvent.keyDown(treeItem, {key: 'u', metaKey: true, shiftKey: true}) - }) + await userEvent.keyboard('{Meta>}{Shift>}u{/Shift}{/Meta}') - expect(screen.getByRole('dialog')).toBeInTheDocument() + await expect.element(page.getByRole('dialog')).toBeInTheDocument() }) From 480561af68f42234bd357e307723df41ad552cf3 Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Mon, 12 Oct 2026 12:13:28 -0500 Subject: [PATCH 2/5] wrap pilot browser interactions in React act Restore the programmatic click for the pointer-only TreeView chevron. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3051e4e7-3e74-4dcd-b1f4-1314e3b9b56e --- contributor-docs/testing.md | 6 +- .../src/Button/__tests__/Button.test.tsx | 12 +- packages/react/src/TreeView/TreeView.test.tsx | 154 +++++++++--------- 3 files changed, 91 insertions(+), 81 deletions(-) diff --git a/contributor-docs/testing.md b/contributor-docs/testing.md index fce3faa3bd2..013d5ef5a1a 100644 --- a/contributor-docs/testing.md +++ b/contributor-docs/testing.md @@ -82,7 +82,7 @@ The Button and TreeView suites pilot [Vitest Browser locators](https://vitest.de Keep RTL for `render`, `rerender`, `unmount`, `renderHook`, cleanup, and explicit React `act` boundaries. Import browser queries and interactions from `vitest/browser`: ```tsx -import {render} from '@testing-library/react' +import {act, render} from '@testing-library/react' import {expect, it, vi} from 'vitest' import {page, userEvent} from 'vitest/browser' import {Button} from '@primer/react' @@ -93,7 +93,7 @@ it('calls onClick', async () => { const button = page.getByRole('button', {name: 'Submit', exact: true}) await expect.element(button).toBeVisible() - await userEvent.click(button) + await act(async () => userEvent.click(button)) expect(onClick).toHaveBeenCalledTimes(1) }) ``` @@ -103,6 +103,8 @@ Use `expect.element` for eventual DOM assertions and `expect.poll` for eventual - Locators resolve again when used. An expanded TreeView can make a previously unique partial name match several items. Use an exact name or scope the locator; don't hide ambiguity with `.first()`. - DOM presence and visibility are different contracts. Button's loading announcement is checked by its exact ID and text because the spinner also contains "Loading". - Keep intentional synthetic events for disabled-click suppression, event cancellation, and specific event payloads. Browser actions wait for actionable elements and don't expose the result of `dispatchEvent`. +- Keep the pointer-only TreeView chevron's programmatic `.click()` exception inside `act`; browser pointer focus on it emits a known focus-zone warning. +- Wrap browser-backed interactions in awaited async `act` boundaries. Vitest's browser `userEvent` doesn't wrap React state updates in `act`; eventual assertions don't replace that boundary. - Keep programmatic focus in `act` when testing focus redirection. Preserve fake timers and synchronous assertions after explicit timer advancement. - Use `.element()` for snapshots, computed styles, structural checks, or capturing a particular node before it unmounts. A locator follows current DOM state; it doesn't retain the identity of a detached node. diff --git a/packages/react/src/Button/__tests__/Button.test.tsx b/packages/react/src/Button/__tests__/Button.test.tsx index f5c1648093a..e6ae316604d 100644 --- a/packages/react/src/Button/__tests__/Button.test.tsx +++ b/packages/react/src/Button/__tests__/Button.test.tsx @@ -1,6 +1,6 @@ import {page, userEvent} from 'vitest/browser' import {SearchIcon, HeartIcon} from '@primer/octicons-react' -import {render, fireEvent} from '@testing-library/react' +import {render, fireEvent, act} from '@testing-library/react' import {describe, it, expect, vi} from 'vitest' import React from 'react' import {IconButton, Button, LinkButton} from '../../Button' @@ -45,7 +45,7 @@ describe('Button', () => { const onClick = vi.fn() render() const button = page.getByRole('button') - await userEvent.click(button) + await act(async () => userEvent.click(button)) expect(onClick).toHaveBeenCalledTimes(1) }) @@ -227,7 +227,7 @@ describe('Button', () => { const buttonNode = page.getByRole('button') - await userEvent.click(buttonNode) + await act(async () => userEvent.click(buttonNode)) await expect.element(buttonNode).toHaveAttribute('aria-describedby', `${buttonId}-loading-announcement`) @@ -247,7 +247,7 @@ describe('Button', () => { const buttonNode = page.getByRole('button') - await userEvent.click(buttonNode) + await act(async () => userEvent.click(buttonNode)) await expect.element(buttonNode).toHaveAttribute('aria-describedby', `${buttonId}-loading-announcement`) @@ -270,7 +270,7 @@ describe('Button', () => { const buttonNode = page.getByRole('button') - await userEvent.click(buttonNode) + await act(async () => userEvent.click(buttonNode)) const buttonDescribedBy = page.getByRole('button').element().getAttribute('aria-describedby') const loadingAnnouncementId = `${buttonId}-loading-announcement` @@ -291,7 +291,7 @@ describe('Button', () => { expect(buttonNode.element().getAttribute('aria-disabled')).not.toBe('true') - await userEvent.click(buttonNode) + await act(async () => userEvent.click(buttonNode)) await expect.element(buttonNode).toHaveAttribute('aria-disabled', 'true') }) diff --git a/packages/react/src/TreeView/TreeView.test.tsx b/packages/react/src/TreeView/TreeView.test.tsx index 2c40857f273..ecf2d364428 100644 --- a/packages/react/src/TreeView/TreeView.test.tsx +++ b/packages/react/src/TreeView/TreeView.test.tsx @@ -227,13 +227,13 @@ describe('Markup', () => { let treeitem = page.getByRole('treeitem', {name: 'Item 1', exact: true}) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'false') - await userEvent.click(treeitem) + await act(async () => userEvent.click(treeitem)) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'true') treeitem = page.getByRole('treeitem', {name: 'Item 2', exact: true}) await expect.element(treeitem).not.toHaveAttribute('aria-expanded') - await userEvent.click(treeitem) + await act(async () => userEvent.click(treeitem)) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'true') }) @@ -322,7 +322,11 @@ describe('Markup', () => { // Move focus to tree const item1 = page.getByRole('treeitem', {name: /Item 1/}) const toggle = item1.element().querySelector('.PRIVATE_TreeView-item-toggle') as HTMLElement - await userEvent.click(toggle) + act(() => { + // The pointer-only chevron retains its programmatic click exception to + // avoid the known focus-zone warning from browser pointer focus. + toggle.click() + }) // Focus should be on current treeitem const subItem1 = page.getByRole('treeitem', {name: /SubItem 1/}) @@ -521,7 +525,7 @@ describe('Markup', () => { ) const item = page.getByRole('treeitem', {name: /Item 1/}) - await userEvent.click(item) + await act(async () => userEvent.click(item)) expect(onSelect).toHaveBeenCalledTimes(1) }) }) @@ -572,25 +576,25 @@ describe('Keyboard interactions', () => { }) // Press ↓ - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) // item 1.1 should be focused await expect.element(item11).toHaveFocus() // Press ↓ - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) // item 2 should be focused await expect.element(item2).toHaveFocus() // Press ↓ - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) // item 3 should have focus (skips item 2.1 and item 2.2 because they are hidden) await expect.element(item3).toHaveFocus() // Press ↓ - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) // focus should not change (item 3 is the last visible treeitem) await expect.element(item3).toHaveFocus() @@ -641,34 +645,34 @@ describe('Keyboard interactions', () => { }) // Press ↓ 4 times to move aria-activedescendant to item 3 - await userEvent.keyboard('{ArrowDown}') - await userEvent.keyboard('{ArrowDown}') - await userEvent.keyboard('{ArrowDown}') - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) + await act(async () => userEvent.keyboard('{ArrowDown}')) + await act(async () => userEvent.keyboard('{ArrowDown}')) + await act(async () => userEvent.keyboard('{ArrowDown}')) // item 3 should be focused await expect.element(item3).toHaveFocus() // Press ↑ - await userEvent.keyboard('{ArrowUp}') + await act(async () => userEvent.keyboard('{ArrowUp}')) // Item 2 should have focus (skips item 2.1 and item 2.2 because they are hidden) await expect.element(item2).toHaveFocus() // Press ↑ - await userEvent.keyboard('{ArrowUp}') + await act(async () => userEvent.keyboard('{ArrowUp}')) // Item 1.1 should be focused await expect.element(item11).toHaveFocus() // Press ↑ - await userEvent.keyboard('{ArrowUp}') + await act(async () => userEvent.keyboard('{ArrowUp}')) // Item 1 should be focused await expect.element(item1).toHaveFocus() // Press ↑ - await userEvent.keyboard('{ArrowUp}') + await act(async () => userEvent.keyboard('{ArrowUp}')) // Focus should not change (item 1 is the first visible treeitem) await expect.element(item1).toHaveFocus() @@ -706,7 +710,7 @@ describe('Keyboard interactions', () => { }) // Press ← - await userEvent.keyboard('{ArrowLeft}') + await act(async () => userEvent.keyboard('{ArrowLeft}')) // aria-expanded should now be false await expect.element(parentItem).toHaveAttribute('aria-expanded', 'false') @@ -746,7 +750,7 @@ describe('Keyboard interactions', () => { }) // Press ← - await userEvent.keyboard('{ArrowLeft}') + await act(async () => userEvent.keyboard('{ArrowLeft}')) // aria-expanded should still be false await expect.element(parentItem).toHaveAttribute('aria-expanded', 'false') @@ -773,7 +777,7 @@ describe('Keyboard interactions', () => { }) // Press ← - await userEvent.keyboard('{ArrowLeft}') + await act(async () => userEvent.keyboard('{ArrowLeft}')) // Focus should not change await expect.element(item).toHaveFocus() @@ -807,14 +811,14 @@ describe('Keyboard interactions', () => { }) // Press ↓ 2 times to move focus to child 2 - await userEvent.keyboard('{ArrowDown}') - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) + await act(async () => userEvent.keyboard('{ArrowDown}')) // Child 2 should be focused await expect.element(child2).toHaveFocus() // Press ← - await userEvent.keyboard('{ArrowLeft}') + await act(async () => userEvent.keyboard('{ArrowLeft}')) // Parent item should be focused await expect.element(parentItem).toHaveFocus() @@ -853,14 +857,14 @@ describe('Keyboard interactions', () => { }) // Press ↓ 2 times to move focus to nested parent - await userEvent.keyboard('{ArrowDown}') - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) + await act(async () => userEvent.keyboard('{ArrowDown}')) // Nested parent item should be focused await expect.element(nestedParentItem).toHaveFocus() // Press ← - await userEvent.keyboard('{ArrowLeft}') + await act(async () => userEvent.keyboard('{ArrowLeft}')) // Parent item should be focused await expect.element(parentItem).toHaveFocus() @@ -894,7 +898,7 @@ describe('Keyboard interactions', () => { }) // Press → - await userEvent.keyboard('{ArrowRight}') + await act(async () => userEvent.keyboard('{ArrowRight}')) // aria-expanded should now be true await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') @@ -933,7 +937,7 @@ describe('Keyboard interactions', () => { }) await expect.element(parentItem).toHaveFocus() - await userEvent.keyboard('{ArrowRight}') + await act(async () => userEvent.keyboard('{ArrowRight}')) const childItem = page.getByRole('treeitem', { name: 'Child', @@ -975,13 +979,13 @@ describe('Keyboard interactions', () => { }) // Press ↓ to move focus to child 1 - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) // Child 1 should be focused await expect.element(child1).toHaveFocus() // Press → - await userEvent.keyboard('{ArrowRight}') + await act(async () => userEvent.keyboard('{ArrowRight}')) // Focus should not change await expect.element(child1).toHaveFocus() @@ -1014,7 +1018,7 @@ describe('Keyboard interactions', () => { }) // Press Backspace - await userEvent.keyboard('{Backspace}') + await act(async () => userEvent.keyboard('{Backspace}')) await expect.element(parentItem).toHaveFocus() }) @@ -1049,7 +1053,7 @@ describe('Keyboard interactions', () => { }) // Press Backspace - await userEvent.keyboard('{Backspace}') + await act(async () => userEvent.keyboard('{Backspace}')) // aria-expanded should stay set as true await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') @@ -1099,13 +1103,13 @@ describe('Keyboard interactions', () => { }) // Press ↓ 2 times to move focus to parent 3 - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) // Parent 3 should be focused await expect.element(parent3).toHaveFocus() // Press Home - await userEvent.keyboard('{Home}') + await act(async () => userEvent.keyboard('{Home}')) // Parent 1 should be focused await expect.element(parent1).toHaveFocus() @@ -1152,16 +1156,16 @@ describe('Keyboard interactions', () => { }) // Press End - await userEvent.keyboard('{End}') + await act(async () => userEvent.keyboard('{End}')) // Parent 3 should be focused await expect.element(parent3).toHaveFocus() // Press → to expand parent 3 - await userEvent.keyboard('{ArrowRight}') + await act(async () => userEvent.keyboard('{ArrowRight}')) // Press End - await userEvent.keyboard('{End}') + await act(async () => userEvent.keyboard('{End}')) const child3 = page.getByRole('treeitem', { name: 'Child 3', @@ -1215,7 +1219,7 @@ describe('Keyboard interactions', () => { }) // Press Enter - await userEvent.keyboard('{Enter}') + await act(async () => userEvent.keyboard('{Enter}')) // onSelect should have been called expect(onSelect).toHaveBeenCalledTimes(1) @@ -1256,7 +1260,7 @@ describe('Keyboard interactions', () => { await expect.element(parent).toHaveAttribute('aria-expanded', 'false') // Press Enter - await userEvent.keyboard('{Enter}') + await act(async () => userEvent.keyboard('{Enter}')) // aria-expanded should now be true await expect.element(parent).toHaveAttribute('aria-expanded', 'true') @@ -1265,7 +1269,7 @@ describe('Keyboard interactions', () => { await expect.element(page.getByRole('group')).toBeVisible() // Press Enter - await userEvent.keyboard('{Enter}') + await act(async () => userEvent.keyboard('{Enter}')) // aria-expanded should now be false await expect.element(parent).toHaveAttribute('aria-expanded', 'false') @@ -1317,7 +1321,7 @@ describe('Keyboard interactions', () => { }) // Press Enter - await userEvent.keyboard(' ') + await act(async () => userEvent.keyboard(' ')) // onSelect should have been called expect(onSelect).toHaveBeenCalledTimes(1) @@ -1358,7 +1362,7 @@ describe('Keyboard interactions', () => { await expect.element(parent).toHaveAttribute('aria-expanded', 'false') // Press Enter - await userEvent.keyboard('{Enter}') + await act(async () => userEvent.keyboard('{Enter}')) // aria-expanded should now be true await expect.element(parent).toHaveAttribute('aria-expanded', 'true') @@ -1367,7 +1371,7 @@ describe('Keyboard interactions', () => { await expect.element(page.getByRole('group')).toBeVisible() // Press Enter - await userEvent.keyboard('{Enter}') + await act(async () => userEvent.keyboard('{Enter}')) // aria-expanded should now be false await expect.element(parent).toHaveAttribute('aria-expanded', 'false') @@ -1408,7 +1412,7 @@ describe('Keyboard interactions', () => { }) // Press C - await userEvent.keyboard('c') + await act(async () => userEvent.keyboard('c')) // Cherry should be focused await expect.element(cherry).toHaveFocus() @@ -1441,7 +1445,7 @@ describe('Keyboard interactions', () => { await expect.element(apple).toHaveFocus() // Press Z - await userEvent.keyboard('z') + await act(async () => userEvent.keyboard('z')) // Apple should still be focused await expect.element(apple).toHaveFocus() @@ -1473,9 +1477,9 @@ describe('Keyboard interactions', () => { }) // Press C + A + N - await userEvent.keyboard('c') - await userEvent.keyboard('a') - await userEvent.keyboard('n') + await act(async () => userEvent.keyboard('c')) + await act(async () => userEvent.keyboard('a')) + await act(async () => userEvent.keyboard('n')) // Cantalope should be focused await expect.element(cantalope).toHaveFocus() @@ -1511,13 +1515,13 @@ describe('Keyboard interactions', () => { }) // Press ↓ to move focus to cherry - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) // Cherry should be focused await expect.element(cherry).toHaveFocus() // Press C - await userEvent.keyboard('c') + await act(async () => userEvent.keyboard('c')) // Cantalope should be focused await expect.element(cantalope).toHaveFocus() @@ -1550,14 +1554,14 @@ describe('Keyboard interactions', () => { }) // Press ↓ 2 times to move focus to cantalope - await userEvent.keyboard('{ArrowDown}') - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) + await act(async () => userEvent.keyboard('{ArrowDown}')) // Cantalope should be focused await expect.element(cantalope).toHaveFocus() // Press C - await userEvent.keyboard('c') + await act(async () => userEvent.keyboard('c')) // Cucumber should be focused await expect.element(cucumber).toHaveFocus() @@ -1601,22 +1605,22 @@ describe('State', () => { }) // Press Enter to expand item 2 - await userEvent.keyboard('{Enter}') + await act(async () => userEvent.keyboard('{Enter}')) // Item 2 should be expanded await expect.element(item2).toHaveAttribute('aria-expanded', 'true') // Press ↑ to move focus to item 1 - await userEvent.keyboard('{ArrowUp}') + await act(async () => userEvent.keyboard('{ArrowUp}')) // Press Enter to collapse item 1 - await userEvent.keyboard('{Enter}') + await act(async () => userEvent.keyboard('{Enter}')) // Item 1 should be collapsed await expect.element(item1).toHaveAttribute('aria-expanded', 'false') // Press Enter again to expand item 1 - await userEvent.keyboard('{Enter}') + await act(async () => userEvent.keyboard('{Enter}')) // Item 1 should be expanded await expect.element(item1).toHaveAttribute('aria-expanded', 'true') @@ -1670,7 +1674,7 @@ describe('State', () => { }) // Press ← to collapse the parent - await userEvent.keyboard('{ArrowLeft}') + await act(async () => userEvent.keyboard('{ArrowLeft}')) // Parent should be collapsed await expect.element(parent).toHaveAttribute('aria-expanded', 'false') @@ -1726,13 +1730,13 @@ describe('Asynchronous loading', () => { exact: true, }) - await userEvent.click(doneButton) + await act(async () => userEvent.click(doneButton)) // Get live region after the first announcement creates it await expect.poll(() => getLiveRegion().getMessage('polite')).toBe('Parent content loading') const liveRegion = getLiveRegion() - await userEvent.click(doneButton) + await act(async () => userEvent.click(doneButton)) await expect.poll(() => liveRegion.getMessage('polite')).toBe('Parent content loaded') }) @@ -1777,7 +1781,7 @@ describe('Asynchronous loading', () => { parentItem.element().focus() }) - await userEvent.keyboard('{ArrowDown}') + await act(async () => userEvent.keyboard('{ArrowDown}')) // Loading item should be focused await expect.element(loadingItem).toHaveFocus() @@ -1841,7 +1845,7 @@ describe('Asynchronous loading', () => { await expect.element(dialog).toBeVisible() const dialogElement = dialog.element() - await userEvent.keyboard('{Escape}') + await act(async () => userEvent.keyboard('{Escape}')) // Dialog should not be visible expect(dialogElement).not.toBeVisible() @@ -1881,12 +1885,12 @@ describe('Asynchronous loading', () => { }) await expect.element(retryButton).toHaveFocus() - await userEvent.keyboard('{ArrowLeft}') + await act(async () => userEvent.keyboard('{ArrowLeft}')) // Parent item should still be expanded await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') - await userEvent.keyboard('{Backspace}') + await act(async () => userEvent.keyboard('{Backspace}')) // Parent item should still be expanded await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') @@ -1934,10 +1938,12 @@ describe('Asynchronous loading', () => { exact: true, }) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'false') - await userEvent.click( - page.getByText('Item 1', { - exact: true, - }), + await act(async () => + userEvent.click( + page.getByText('Item 1', { + exact: true, + }), + ), ) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'true') @@ -2000,11 +2006,13 @@ describe('Asynchronous loading', () => { ) .not.toHaveAttribute('aria-expanded') - await userEvent.click( - page.getByRole('treeitem', { - name: 'empty child', - exact: true, - }), + await act(async () => + userEvent.click( + page.getByRole('treeitem', { + name: 'empty child', + exact: true, + }), + ), ) // Empty child should have `aria-expanded` when opened @@ -2137,7 +2145,7 @@ it('should activate the dialog for trailing action when keyboard shortcut is use await expect.element(page.getByRole('dialog')).not.toBeInTheDocument() - await userEvent.keyboard('{Meta>}{Shift>}u{/Shift}{/Meta}') + await act(async () => userEvent.keyboard('{Meta>}{Shift>}u{/Shift}{/Meta}')) await expect.element(page.getByRole('dialog')).toBeInTheDocument() }) From 442c963000de12ab3bc03b4b4ed98bfbdc0ce114 Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Tue, 13 Oct 2026 10:29:45 -0500 Subject: [PATCH 3/5] align browser test act setup with github-ui Keep RTL rendering and scoped act boundaries for manual updates. Await Vitest browser interactions directly without forcing act mode for the entire browser suite. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3051e4e7-3e74-4dcd-b1f4-1314e3b9b56e --- contributor-docs/testing.md | 7 +- packages/react/config/vitest/browser/setup.ts | 4 - .../src/Button/__tests__/Button.test.tsx | 12 +- .../__tests__/DataTableGrouping.test.tsx | 3 +- packages/react/src/Portal/Portal.test.tsx | 4 +- .../src/RelativeTime/RelativeTime.test.tsx | 3 +- .../ScrollableRegion.test.tsx | 3 +- .../Skeleton/__tests__/SkeletonBox.test.tsx | 3 +- packages/react/src/Spinner/Spinner.test.tsx | 3 +- packages/react/src/TreeView/TreeView.test.tsx | 148 +++++++++--------- .../UnderlinePanels/UnderlinePanels.test.tsx | 4 +- .../src/hooks/__tests__/useMedia.test.tsx | 3 +- 12 files changed, 92 insertions(+), 105 deletions(-) diff --git a/contributor-docs/testing.md b/contributor-docs/testing.md index 013d5ef5a1a..7d5ec0d2711 100644 --- a/contributor-docs/testing.md +++ b/contributor-docs/testing.md @@ -82,7 +82,7 @@ The Button and TreeView suites pilot [Vitest Browser locators](https://vitest.de Keep RTL for `render`, `rerender`, `unmount`, `renderHook`, cleanup, and explicit React `act` boundaries. Import browser queries and interactions from `vitest/browser`: ```tsx -import {act, render} from '@testing-library/react' +import {render} from '@testing-library/react' import {expect, it, vi} from 'vitest' import {page, userEvent} from 'vitest/browser' import {Button} from '@primer/react' @@ -93,7 +93,7 @@ it('calls onClick', async () => { const button = page.getByRole('button', {name: 'Submit', exact: true}) await expect.element(button).toBeVisible() - await act(async () => userEvent.click(button)) + await userEvent.click(button) expect(onClick).toHaveBeenCalledTimes(1) }) ``` @@ -104,8 +104,9 @@ Use `expect.element` for eventual DOM assertions and `expect.poll` for eventual - DOM presence and visibility are different contracts. Button's loading announcement is checked by its exact ID and text because the spinner also contains "Loading". - Keep intentional synthetic events for disabled-click suppression, event cancellation, and specific event payloads. Browser actions wait for actionable elements and don't expose the result of `dispatchEvent`. - Keep the pointer-only TreeView chevron's programmatic `.click()` exception inside `act`; browser pointer focus on it emits a known focus-zone warning. -- Wrap browser-backed interactions in awaited async `act` boundaries. Vitest's browser `userEvent` doesn't wrap React state updates in `act`; eventual assertions don't replace that boundary. +- Await browser interactions directly, without wrapping them in `act`. The browser setup leaves React's act environment to RTL instead of forcing `IS_REACT_ACT_ENVIRONMENT` on for the whole suite. - Keep programmatic focus in `act` when testing focus redirection. Preserve fake timers and synchronous assertions after explicit timer advancement. +- Import `act` from `@testing-library/react` for manual updates, including focus, timers, hooks, and hydration. RTL scopes the act environment to that operation and restores it afterward. - Use `.element()` for snapshots, computed styles, structural checks, or capturing a particular node before it unmounts. A locator follows current DOM state; it doesn't retain the identity of a detached node. The pilot leaves shared helpers, Node/SSR tests, and the separate Playwright visual and accessibility suites unchanged. It doesn't require removing RTL dependencies. diff --git a/packages/react/config/vitest/browser/setup.ts b/packages/react/config/vitest/browser/setup.ts index f2401e64744..8429c62d432 100644 --- a/packages/react/config/vitest/browser/setup.ts +++ b/packages/react/config/vitest/browser/setup.ts @@ -27,10 +27,6 @@ afterEach(() => { cleanup() }) -// @ts-expect-error this is needed for act() from React -// @see https://react.dev/reference/react/act#error-the-current-testing-environment-is-not-configured-to-support-act -globalThis.IS_REACT_ACT_ENVIRONMENT = true - // eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope document.documentElement.setAttribute('data-color-mode', 'auto') // eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope diff --git a/packages/react/src/Button/__tests__/Button.test.tsx b/packages/react/src/Button/__tests__/Button.test.tsx index e6ae316604d..f5c1648093a 100644 --- a/packages/react/src/Button/__tests__/Button.test.tsx +++ b/packages/react/src/Button/__tests__/Button.test.tsx @@ -1,6 +1,6 @@ import {page, userEvent} from 'vitest/browser' import {SearchIcon, HeartIcon} from '@primer/octicons-react' -import {render, fireEvent, act} from '@testing-library/react' +import {render, fireEvent} from '@testing-library/react' import {describe, it, expect, vi} from 'vitest' import React from 'react' import {IconButton, Button, LinkButton} from '../../Button' @@ -45,7 +45,7 @@ describe('Button', () => { const onClick = vi.fn() render() const button = page.getByRole('button') - await act(async () => userEvent.click(button)) + await userEvent.click(button) expect(onClick).toHaveBeenCalledTimes(1) }) @@ -227,7 +227,7 @@ describe('Button', () => { const buttonNode = page.getByRole('button') - await act(async () => userEvent.click(buttonNode)) + await userEvent.click(buttonNode) await expect.element(buttonNode).toHaveAttribute('aria-describedby', `${buttonId}-loading-announcement`) @@ -247,7 +247,7 @@ describe('Button', () => { const buttonNode = page.getByRole('button') - await act(async () => userEvent.click(buttonNode)) + await userEvent.click(buttonNode) await expect.element(buttonNode).toHaveAttribute('aria-describedby', `${buttonId}-loading-announcement`) @@ -270,7 +270,7 @@ describe('Button', () => { const buttonNode = page.getByRole('button') - await act(async () => userEvent.click(buttonNode)) + await userEvent.click(buttonNode) const buttonDescribedBy = page.getByRole('button').element().getAttribute('aria-describedby') const loadingAnnouncementId = `${buttonId}-loading-announcement` @@ -291,7 +291,7 @@ describe('Button', () => { expect(buttonNode.element().getAttribute('aria-disabled')).not.toBe('true') - await act(async () => userEvent.click(buttonNode)) + await userEvent.click(buttonNode) await expect.element(buttonNode).toHaveAttribute('aria-disabled', 'true') }) diff --git a/packages/react/src/DataTable/__tests__/DataTableGrouping.test.tsx b/packages/react/src/DataTable/__tests__/DataTableGrouping.test.tsx index 1643783edbe..d72f55f6134 100644 --- a/packages/react/src/DataTable/__tests__/DataTableGrouping.test.tsx +++ b/packages/react/src/DataTable/__tests__/DataTableGrouping.test.tsx @@ -1,6 +1,5 @@ -import {render, renderHook, screen, within} from '@testing-library/react' +import {act, render, renderHook, screen, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' -import {act} from 'react' import {hydrateRoot, type Root} from 'react-dom/client' import {renderToString} from 'react-dom/server' import {describe, expect, it, vi} from 'vitest' diff --git a/packages/react/src/Portal/Portal.test.tsx b/packages/react/src/Portal/Portal.test.tsx index 76788cc7cd4..21f382f8fa1 100644 --- a/packages/react/src/Portal/Portal.test.tsx +++ b/packages/react/src/Portal/Portal.test.tsx @@ -1,9 +1,9 @@ import {describe, expect, it, vi} from 'vitest' import Portal, {registerPortalRoot, PortalContext} from '../Portal/index' -import {render} from '@testing-library/react' +import {act, render} from '@testing-library/react' import BaseStyles from '../BaseStyles' -import React, {act} from 'react' +import React from 'react' import {hydrateRoot, type Root} from 'react-dom/client' import {renderToString} from 'react-dom/server' diff --git a/packages/react/src/RelativeTime/RelativeTime.test.tsx b/packages/react/src/RelativeTime/RelativeTime.test.tsx index 79e9c063a4d..b13bafaf65c 100644 --- a/packages/react/src/RelativeTime/RelativeTime.test.tsx +++ b/packages/react/src/RelativeTime/RelativeTime.test.tsx @@ -1,9 +1,8 @@ -import {act} from 'react' import {hydrateRoot, type Root} from 'react-dom/client' import {renderToString} from 'react-dom/server' import {describe, expect, it, vi} from 'vitest' import RelativeTime from '.' -import {render} from '@testing-library/react' +import {act, render} from '@testing-library/react' import {implementsClassName} from '../utils/testing' describe('RelativeTime', () => { diff --git a/packages/react/src/ScrollableRegion/ScrollableRegion.test.tsx b/packages/react/src/ScrollableRegion/ScrollableRegion.test.tsx index dc95570d83b..01475b538e8 100644 --- a/packages/react/src/ScrollableRegion/ScrollableRegion.test.tsx +++ b/packages/react/src/ScrollableRegion/ScrollableRegion.test.tsx @@ -1,6 +1,5 @@ import {describe, expect, test, beforeEach, afterEach} from 'vitest' -import {render, screen} from '@testing-library/react' -import {act} from 'react' +import {act, render, screen} from '@testing-library/react' import {ScrollableRegion} from '../ScrollableRegion' import {implementsClassName} from '../utils/testing' import classes from './ScrollableRegion.module.css' diff --git a/packages/react/src/Skeleton/__tests__/SkeletonBox.test.tsx b/packages/react/src/Skeleton/__tests__/SkeletonBox.test.tsx index b70bc72576f..cf7a6d4d110 100644 --- a/packages/react/src/Skeleton/__tests__/SkeletonBox.test.tsx +++ b/packages/react/src/Skeleton/__tests__/SkeletonBox.test.tsx @@ -1,9 +1,8 @@ -import {render} from '@testing-library/react' +import {act, render} from '@testing-library/react' import {beforeEach, afterEach, describe, expect, it, vi} from 'vitest' import {SkeletonBox} from '../SkeletonBox' import classes from '../SkeletonBox.module.css' import {implementsClassName} from '../../utils/testing' -import {act} from 'react' describe('SkeletonBox', () => { implementsClassName(SkeletonBox, classes.SkeletonBox) diff --git a/packages/react/src/Spinner/Spinner.test.tsx b/packages/react/src/Spinner/Spinner.test.tsx index 6688a3f9d3c..dc11eb9be28 100644 --- a/packages/react/src/Spinner/Spinner.test.tsx +++ b/packages/react/src/Spinner/Spinner.test.tsx @@ -1,8 +1,7 @@ import type {SpinnerProps} from '.' import Spinner from '.' -import {render, screen} from '@testing-library/react' +import {act, render, screen} from '@testing-library/react' import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest' -import {act} from 'react' import {implementsClassName} from '../utils/testing' import classes from './Spinner.module.css' diff --git a/packages/react/src/TreeView/TreeView.test.tsx b/packages/react/src/TreeView/TreeView.test.tsx index ecf2d364428..24370beb8c0 100644 --- a/packages/react/src/TreeView/TreeView.test.tsx +++ b/packages/react/src/TreeView/TreeView.test.tsx @@ -227,13 +227,13 @@ describe('Markup', () => { let treeitem = page.getByRole('treeitem', {name: 'Item 1', exact: true}) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'false') - await act(async () => userEvent.click(treeitem)) + await userEvent.click(treeitem) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'true') treeitem = page.getByRole('treeitem', {name: 'Item 2', exact: true}) await expect.element(treeitem).not.toHaveAttribute('aria-expanded') - await act(async () => userEvent.click(treeitem)) + await userEvent.click(treeitem) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'true') }) @@ -525,7 +525,7 @@ describe('Markup', () => { ) const item = page.getByRole('treeitem', {name: /Item 1/}) - await act(async () => userEvent.click(item)) + await userEvent.click(item) expect(onSelect).toHaveBeenCalledTimes(1) }) }) @@ -576,25 +576,25 @@ describe('Keyboard interactions', () => { }) // Press ↓ - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') // item 1.1 should be focused await expect.element(item11).toHaveFocus() // Press ↓ - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') // item 2 should be focused await expect.element(item2).toHaveFocus() // Press ↓ - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') // item 3 should have focus (skips item 2.1 and item 2.2 because they are hidden) await expect.element(item3).toHaveFocus() // Press ↓ - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') // focus should not change (item 3 is the last visible treeitem) await expect.element(item3).toHaveFocus() @@ -645,34 +645,34 @@ describe('Keyboard interactions', () => { }) // Press ↓ 4 times to move aria-activedescendant to item 3 - await act(async () => userEvent.keyboard('{ArrowDown}')) - await act(async () => userEvent.keyboard('{ArrowDown}')) - await act(async () => userEvent.keyboard('{ArrowDown}')) - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') // item 3 should be focused await expect.element(item3).toHaveFocus() // Press ↑ - await act(async () => userEvent.keyboard('{ArrowUp}')) + await userEvent.keyboard('{ArrowUp}') // Item 2 should have focus (skips item 2.1 and item 2.2 because they are hidden) await expect.element(item2).toHaveFocus() // Press ↑ - await act(async () => userEvent.keyboard('{ArrowUp}')) + await userEvent.keyboard('{ArrowUp}') // Item 1.1 should be focused await expect.element(item11).toHaveFocus() // Press ↑ - await act(async () => userEvent.keyboard('{ArrowUp}')) + await userEvent.keyboard('{ArrowUp}') // Item 1 should be focused await expect.element(item1).toHaveFocus() // Press ↑ - await act(async () => userEvent.keyboard('{ArrowUp}')) + await userEvent.keyboard('{ArrowUp}') // Focus should not change (item 1 is the first visible treeitem) await expect.element(item1).toHaveFocus() @@ -710,7 +710,7 @@ describe('Keyboard interactions', () => { }) // Press ← - await act(async () => userEvent.keyboard('{ArrowLeft}')) + await userEvent.keyboard('{ArrowLeft}') // aria-expanded should now be false await expect.element(parentItem).toHaveAttribute('aria-expanded', 'false') @@ -750,7 +750,7 @@ describe('Keyboard interactions', () => { }) // Press ← - await act(async () => userEvent.keyboard('{ArrowLeft}')) + await userEvent.keyboard('{ArrowLeft}') // aria-expanded should still be false await expect.element(parentItem).toHaveAttribute('aria-expanded', 'false') @@ -777,7 +777,7 @@ describe('Keyboard interactions', () => { }) // Press ← - await act(async () => userEvent.keyboard('{ArrowLeft}')) + await userEvent.keyboard('{ArrowLeft}') // Focus should not change await expect.element(item).toHaveFocus() @@ -811,14 +811,14 @@ describe('Keyboard interactions', () => { }) // Press ↓ 2 times to move focus to child 2 - await act(async () => userEvent.keyboard('{ArrowDown}')) - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') // Child 2 should be focused await expect.element(child2).toHaveFocus() // Press ← - await act(async () => userEvent.keyboard('{ArrowLeft}')) + await userEvent.keyboard('{ArrowLeft}') // Parent item should be focused await expect.element(parentItem).toHaveFocus() @@ -857,14 +857,14 @@ describe('Keyboard interactions', () => { }) // Press ↓ 2 times to move focus to nested parent - await act(async () => userEvent.keyboard('{ArrowDown}')) - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') // Nested parent item should be focused await expect.element(nestedParentItem).toHaveFocus() // Press ← - await act(async () => userEvent.keyboard('{ArrowLeft}')) + await userEvent.keyboard('{ArrowLeft}') // Parent item should be focused await expect.element(parentItem).toHaveFocus() @@ -898,7 +898,7 @@ describe('Keyboard interactions', () => { }) // Press → - await act(async () => userEvent.keyboard('{ArrowRight}')) + await userEvent.keyboard('{ArrowRight}') // aria-expanded should now be true await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') @@ -937,7 +937,7 @@ describe('Keyboard interactions', () => { }) await expect.element(parentItem).toHaveFocus() - await act(async () => userEvent.keyboard('{ArrowRight}')) + await userEvent.keyboard('{ArrowRight}') const childItem = page.getByRole('treeitem', { name: 'Child', @@ -979,13 +979,13 @@ describe('Keyboard interactions', () => { }) // Press ↓ to move focus to child 1 - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') // Child 1 should be focused await expect.element(child1).toHaveFocus() // Press → - await act(async () => userEvent.keyboard('{ArrowRight}')) + await userEvent.keyboard('{ArrowRight}') // Focus should not change await expect.element(child1).toHaveFocus() @@ -1018,7 +1018,7 @@ describe('Keyboard interactions', () => { }) // Press Backspace - await act(async () => userEvent.keyboard('{Backspace}')) + await userEvent.keyboard('{Backspace}') await expect.element(parentItem).toHaveFocus() }) @@ -1053,7 +1053,7 @@ describe('Keyboard interactions', () => { }) // Press Backspace - await act(async () => userEvent.keyboard('{Backspace}')) + await userEvent.keyboard('{Backspace}') // aria-expanded should stay set as true await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') @@ -1103,13 +1103,13 @@ describe('Keyboard interactions', () => { }) // Press ↓ 2 times to move focus to parent 3 - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') // Parent 3 should be focused await expect.element(parent3).toHaveFocus() // Press Home - await act(async () => userEvent.keyboard('{Home}')) + await userEvent.keyboard('{Home}') // Parent 1 should be focused await expect.element(parent1).toHaveFocus() @@ -1156,16 +1156,16 @@ describe('Keyboard interactions', () => { }) // Press End - await act(async () => userEvent.keyboard('{End}')) + await userEvent.keyboard('{End}') // Parent 3 should be focused await expect.element(parent3).toHaveFocus() // Press → to expand parent 3 - await act(async () => userEvent.keyboard('{ArrowRight}')) + await userEvent.keyboard('{ArrowRight}') // Press End - await act(async () => userEvent.keyboard('{End}')) + await userEvent.keyboard('{End}') const child3 = page.getByRole('treeitem', { name: 'Child 3', @@ -1219,7 +1219,7 @@ describe('Keyboard interactions', () => { }) // Press Enter - await act(async () => userEvent.keyboard('{Enter}')) + await userEvent.keyboard('{Enter}') // onSelect should have been called expect(onSelect).toHaveBeenCalledTimes(1) @@ -1260,7 +1260,7 @@ describe('Keyboard interactions', () => { await expect.element(parent).toHaveAttribute('aria-expanded', 'false') // Press Enter - await act(async () => userEvent.keyboard('{Enter}')) + await userEvent.keyboard('{Enter}') // aria-expanded should now be true await expect.element(parent).toHaveAttribute('aria-expanded', 'true') @@ -1269,7 +1269,7 @@ describe('Keyboard interactions', () => { await expect.element(page.getByRole('group')).toBeVisible() // Press Enter - await act(async () => userEvent.keyboard('{Enter}')) + await userEvent.keyboard('{Enter}') // aria-expanded should now be false await expect.element(parent).toHaveAttribute('aria-expanded', 'false') @@ -1321,7 +1321,7 @@ describe('Keyboard interactions', () => { }) // Press Enter - await act(async () => userEvent.keyboard(' ')) + await userEvent.keyboard(' ') // onSelect should have been called expect(onSelect).toHaveBeenCalledTimes(1) @@ -1362,7 +1362,7 @@ describe('Keyboard interactions', () => { await expect.element(parent).toHaveAttribute('aria-expanded', 'false') // Press Enter - await act(async () => userEvent.keyboard('{Enter}')) + await userEvent.keyboard('{Enter}') // aria-expanded should now be true await expect.element(parent).toHaveAttribute('aria-expanded', 'true') @@ -1371,7 +1371,7 @@ describe('Keyboard interactions', () => { await expect.element(page.getByRole('group')).toBeVisible() // Press Enter - await act(async () => userEvent.keyboard('{Enter}')) + await userEvent.keyboard('{Enter}') // aria-expanded should now be false await expect.element(parent).toHaveAttribute('aria-expanded', 'false') @@ -1412,7 +1412,7 @@ describe('Keyboard interactions', () => { }) // Press C - await act(async () => userEvent.keyboard('c')) + await userEvent.keyboard('c') // Cherry should be focused await expect.element(cherry).toHaveFocus() @@ -1445,7 +1445,7 @@ describe('Keyboard interactions', () => { await expect.element(apple).toHaveFocus() // Press Z - await act(async () => userEvent.keyboard('z')) + await userEvent.keyboard('z') // Apple should still be focused await expect.element(apple).toHaveFocus() @@ -1477,9 +1477,9 @@ describe('Keyboard interactions', () => { }) // Press C + A + N - await act(async () => userEvent.keyboard('c')) - await act(async () => userEvent.keyboard('a')) - await act(async () => userEvent.keyboard('n')) + await userEvent.keyboard('c') + await userEvent.keyboard('a') + await userEvent.keyboard('n') // Cantalope should be focused await expect.element(cantalope).toHaveFocus() @@ -1515,13 +1515,13 @@ describe('Keyboard interactions', () => { }) // Press ↓ to move focus to cherry - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') // Cherry should be focused await expect.element(cherry).toHaveFocus() // Press C - await act(async () => userEvent.keyboard('c')) + await userEvent.keyboard('c') // Cantalope should be focused await expect.element(cantalope).toHaveFocus() @@ -1554,14 +1554,14 @@ describe('Keyboard interactions', () => { }) // Press ↓ 2 times to move focus to cantalope - await act(async () => userEvent.keyboard('{ArrowDown}')) - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') + await userEvent.keyboard('{ArrowDown}') // Cantalope should be focused await expect.element(cantalope).toHaveFocus() // Press C - await act(async () => userEvent.keyboard('c')) + await userEvent.keyboard('c') // Cucumber should be focused await expect.element(cucumber).toHaveFocus() @@ -1605,22 +1605,22 @@ describe('State', () => { }) // Press Enter to expand item 2 - await act(async () => userEvent.keyboard('{Enter}')) + await userEvent.keyboard('{Enter}') // Item 2 should be expanded await expect.element(item2).toHaveAttribute('aria-expanded', 'true') // Press ↑ to move focus to item 1 - await act(async () => userEvent.keyboard('{ArrowUp}')) + await userEvent.keyboard('{ArrowUp}') // Press Enter to collapse item 1 - await act(async () => userEvent.keyboard('{Enter}')) + await userEvent.keyboard('{Enter}') // Item 1 should be collapsed await expect.element(item1).toHaveAttribute('aria-expanded', 'false') // Press Enter again to expand item 1 - await act(async () => userEvent.keyboard('{Enter}')) + await userEvent.keyboard('{Enter}') // Item 1 should be expanded await expect.element(item1).toHaveAttribute('aria-expanded', 'true') @@ -1674,7 +1674,7 @@ describe('State', () => { }) // Press ← to collapse the parent - await act(async () => userEvent.keyboard('{ArrowLeft}')) + await userEvent.keyboard('{ArrowLeft}') // Parent should be collapsed await expect.element(parent).toHaveAttribute('aria-expanded', 'false') @@ -1730,13 +1730,13 @@ describe('Asynchronous loading', () => { exact: true, }) - await act(async () => userEvent.click(doneButton)) + await userEvent.click(doneButton) // Get live region after the first announcement creates it await expect.poll(() => getLiveRegion().getMessage('polite')).toBe('Parent content loading') const liveRegion = getLiveRegion() - await act(async () => userEvent.click(doneButton)) + await userEvent.click(doneButton) await expect.poll(() => liveRegion.getMessage('polite')).toBe('Parent content loaded') }) @@ -1781,7 +1781,7 @@ describe('Asynchronous loading', () => { parentItem.element().focus() }) - await act(async () => userEvent.keyboard('{ArrowDown}')) + await userEvent.keyboard('{ArrowDown}') // Loading item should be focused await expect.element(loadingItem).toHaveFocus() @@ -1845,7 +1845,7 @@ describe('Asynchronous loading', () => { await expect.element(dialog).toBeVisible() const dialogElement = dialog.element() - await act(async () => userEvent.keyboard('{Escape}')) + await userEvent.keyboard('{Escape}') // Dialog should not be visible expect(dialogElement).not.toBeVisible() @@ -1885,12 +1885,12 @@ describe('Asynchronous loading', () => { }) await expect.element(retryButton).toHaveFocus() - await act(async () => userEvent.keyboard('{ArrowLeft}')) + await userEvent.keyboard('{ArrowLeft}') // Parent item should still be expanded await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') - await act(async () => userEvent.keyboard('{Backspace}')) + await userEvent.keyboard('{Backspace}') // Parent item should still be expanded await expect.element(parentItem).toHaveAttribute('aria-expanded', 'true') @@ -1938,12 +1938,10 @@ describe('Asynchronous loading', () => { exact: true, }) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'false') - await act(async () => - userEvent.click( - page.getByText('Item 1', { - exact: true, - }), - ), + await userEvent.click( + page.getByText('Item 1', { + exact: true, + }), ) await expect.element(treeitem).toHaveAttribute('aria-expanded', 'true') @@ -2006,13 +2004,11 @@ describe('Asynchronous loading', () => { ) .not.toHaveAttribute('aria-expanded') - await act(async () => - userEvent.click( - page.getByRole('treeitem', { - name: 'empty child', - exact: true, - }), - ), + await userEvent.click( + page.getByRole('treeitem', { + name: 'empty child', + exact: true, + }), ) // Empty child should have `aria-expanded` when opened @@ -2145,7 +2141,7 @@ it('should activate the dialog for trailing action when keyboard shortcut is use await expect.element(page.getByRole('dialog')).not.toBeInTheDocument() - await act(async () => userEvent.keyboard('{Meta>}{Shift>}u{/Shift}{/Meta}')) + await userEvent.keyboard('{Meta>}{Shift>}u{/Shift}{/Meta}') await expect.element(page.getByRole('dialog')).toBeInTheDocument() }) diff --git a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.test.tsx b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.test.tsx index a080adf631d..9c37a65361e 100644 --- a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.test.tsx +++ b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.test.tsx @@ -3,8 +3,8 @@ // public API and its integration with Tabs. import type React from 'react' -import {act, useState} from 'react' -import {render, screen} from '@testing-library/react' +import {useState} from 'react' +import {act, render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {describe, it, afterEach, beforeEach, expect, vi} from 'vitest' import {CodeIcon, EyeIcon} from '@primer/octicons-react' diff --git a/packages/react/src/hooks/__tests__/useMedia.test.tsx b/packages/react/src/hooks/__tests__/useMedia.test.tsx index a2579704eb2..729ef97fc46 100644 --- a/packages/react/src/hooks/__tests__/useMedia.test.tsx +++ b/packages/react/src/hooks/__tests__/useMedia.test.tsx @@ -1,6 +1,5 @@ -import {render} from '@testing-library/react' +import {act, render} from '@testing-library/react' import {afterEach, describe, expect, it, vi} from 'vitest' -import {act} from 'react' import ReactDOM from 'react-dom/server' import {useMedia, MatchMedia} from '../useMedia' From e6f72aa33e5641cc814afe1e926212558ea4567f Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Mon, 19 Oct 2026 10:42:51 -0500 Subject: [PATCH 4/5] update interaction testing guidance Remove the Storybook interaction testing recommendation and point component interaction tests to the browser locator pilot. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3051e4e7-3e74-4dcd-b1f4-1314e3b9b56e --- contributor-docs/testing.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/contributor-docs/testing.md b/contributor-docs/testing.md index 7d5ec0d2711..785597d097f 100644 --- a/contributor-docs/testing.md +++ b/contributor-docs/testing.md @@ -13,7 +13,6 @@ - [Running Tests](#running-tests) - [Interaction Tests](#interaction-tests) - [As A Part Of Unit Tests](#as-a-part-of-unit-tests) - - [Storybook Interaction Tests](#storybook-interaction-tests) - [Visual Regression Tests](#visual-regression-tests) - [Prerequisites](#prerequisites) - [Components](#components) @@ -131,13 +130,7 @@ By default, Vitest fails when tests emit unexpected console output in CI. For lo We write our unit tests from a user perspective rather than focusing on implementation details. Simulating events to test user interactions is a core part of our testing practice. -We write user interaction tests leveraging [@testing-library/react](https://testing-library.com/docs/react-testing-library/intro/)’s [userEvent](<[userEvent](https://testing-library.com/docs/user-event/intro/)>) testing helper. - -### Storybook Interaction Tests - -We use [Storybook interactions tests](https://storybook.js.org/docs/react/writing-tests/interaction-testing) to simulate and test complex user interactions in story scenarios. For example, testing a component's overflow behaviour whose responsiveness is managed by its own dynamic width. - -Storybook tests are authored within the components's source directory with the file name of `interactions.stories.tsx` +For browser-backed interactions, use locators and `userEvent` from `vitest/browser`, keeping RTL for rendering and lifecycle helpers. Follow the [browser locator pilot](#browser-locator-pilot) for examples and guidance. ## Visual Regression Tests From aa67d0f76b11aa09f992a7978be9d423507c631e Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Tue, 20 Oct 2026 09:27:27 -0500 Subject: [PATCH 5/5] fix DataTable selection hydration act scope Import act from RTL so hydration and unmount configure the React act environment for each operation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3051e4e7-3e74-4dcd-b1f4-1314e3b9b56e --- .../react/src/DataTable/__tests__/DataTableSelection.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/DataTable/__tests__/DataTableSelection.test.tsx b/packages/react/src/DataTable/__tests__/DataTableSelection.test.tsx index 446e6061f95..034d3e4edae 100644 --- a/packages/react/src/DataTable/__tests__/DataTableSelection.test.tsx +++ b/packages/react/src/DataTable/__tests__/DataTableSelection.test.tsx @@ -1,6 +1,6 @@ -import {render, screen, within} from '@testing-library/react' +import {act, render, screen, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' -import React, {act} from 'react' +import React from 'react' import {hydrateRoot, type Root} from 'react-dom/client' import {renderToString} from 'react-dom/server' import {describe, expect, it, vi} from 'vitest'