From bcebd89b71f00a2570e268eb00e1d2ef75162a2d Mon Sep 17 00:00:00 2001 From: Josh Black Date: Fri, 15 May 2026 11:09:15 -0500 Subject: [PATCH 1/4] Add shared Vitest console enforcement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 48 +++++++++++++++---- packages/doc-gen/package.json | 1 + packages/doc-gen/vitest.config.mts | 1 + packages/postcss-preset-primer/package.json | 1 + .../postcss-preset-primer/vitest.config.ts | 1 + packages/react/package.json | 1 + packages/react/vitest.config.browser.mts | 5 +- packages/react/vitest.config.mts | 1 + packages/styled-react/package.json | 1 + .../styled-react/vitest.config.browser.ts | 5 +- packages/styled-react/vitest.config.ts | 1 + packages/vitest-config/package.json | 15 ++++++ packages/vitest-config/setup.ts | 22 +++++++++ packages/vitest-config/tsconfig.json | 7 +++ 14 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 packages/vitest-config/package.json create mode 100644 packages/vitest-config/setup.ts create mode 100644 packages/vitest-config/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 880c4ad5141..d26422e528e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,8 +82,8 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@primer/react": "38.22.0", - "@primer/styled-react": "1.0.7", + "@primer/react": "38.23.0", + "@primer/styled-react": "1.0.8", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.3", @@ -96,8 +96,8 @@ "name": "example-nextjs", "version": "0.0.0", "dependencies": { - "@primer/react": "38.22.0", - "@primer/styled-react": "1.0.7", + "@primer/react": "38.23.0", + "@primer/styled-react": "1.0.8", "next": "^16.1.7", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -139,8 +139,8 @@ "version": "0.0.0", "dependencies": { "@primer/octicons-react": "^19.21.0", - "@primer/react": "38.22.0", - "@primer/styled-react": "1.0.7", + "@primer/react": "38.23.0", + "@primer/styled-react": "1.0.8", "clsx": "^2.1.1", "next": "^16.1.7", "react": "^19.2.0", @@ -7072,6 +7072,10 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@primer/vitest-config": { + "resolved": "packages/vitest-config", + "link": true + }, "node_modules/@publint/pack": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.2.tgz", @@ -11664,7 +11668,6 @@ }, "node_modules/chalk": { "version": "5.4.1", - "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -26684,6 +26687,20 @@ } } }, + "node_modules/vitest-fail-on-console": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/vitest-fail-on-console/-/vitest-fail-on-console-0.10.1.tgz", + "integrity": "sha512-Xjy2SpgND547qSy0s0zYVnh1G/WyGtdjAbi4PFV8mkYRmTq+6NzRUJYdc08BHrw7HJLpO2kMxHFB8PWn7FOVsg==", + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1" + }, + "peerDependencies": { + "@vitest/utils": ">=0.26.2", + "vite": ">=4.5.2", + "vitest": ">=0.26.2" + } + }, "node_modules/vitest/node_modules/@vitest/expect": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", @@ -27316,6 +27333,7 @@ "@babel/plugin-transform-runtime": "^7.29.0", "@babel/preset-env": "^7.29.0", "@babel/preset-typescript": "^7.28.5", + "@primer/vitest-config": "^0.0.0", "@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", @@ -27696,6 +27714,7 @@ "postcss-preset-env": "^10.1.3" }, "devDependencies": { + "@primer/vitest-config": "^0.0.0", "@types/postcss-mixins": "9.0.5", "cssnano": "^7.0.7", "postcss": "^8.4.41", @@ -27709,7 +27728,7 @@ }, "packages/react": { "name": "@primer/react", - "version": "38.22.0", + "version": "38.23.0", "license": "MIT", "dependencies": { "@github/mini-throttle": "^2.1.1", @@ -27746,6 +27765,7 @@ "@figma/code-connect": "1.3.2", "@primer/css": "^21.5.1", "@primer/doc-gen": "^0.0.1", + "@primer/vitest-config": "^0.0.0", "@rollup/plugin-babel": "6.1.0", "@rollup/plugin-commonjs": "29.0.0", "@rollup/plugin-json": "6.1.0", @@ -28082,7 +28102,7 @@ }, "packages/styled-react": { "name": "@primer/styled-react", - "version": "1.0.7", + "version": "1.0.8", "dependencies": { "@styled-system/css": "^5.1.5", "@styled-system/props": "^5.1.5", @@ -28099,7 +28119,8 @@ "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@primer/primitives": "10.x || 11.x", - "@primer/react": "^38.22.0", + "@primer/react": "^38.23.0", + "@primer/vitest-config": "^0.0.0", "@rollup/plugin-babel": "^6.1.0", "@storybook/react-vite": "^10.3.3", "@types/react": "18.3.11", @@ -28248,6 +28269,13 @@ "funding": { "url": "https://github.com/sponsors/isaacs" } + }, + "packages/vitest-config": { + "name": "@primer/vitest-config", + "version": "0.0.0", + "dependencies": { + "vitest-fail-on-console": "^0.10.1" + } } } } diff --git a/packages/doc-gen/package.json b/packages/doc-gen/package.json index 548f56e0865..e2e261c1490 100644 --- a/packages/doc-gen/package.json +++ b/packages/doc-gen/package.json @@ -39,6 +39,7 @@ "@babel/plugin-transform-runtime": "^7.29.0", "@babel/preset-env": "^7.29.0", "@babel/preset-typescript": "^7.28.5", + "@primer/vitest-config": "^0.0.0", "@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", diff --git a/packages/doc-gen/vitest.config.mts b/packages/doc-gen/vitest.config.mts index 1f571e49350..5aed29b3f45 100644 --- a/packages/doc-gen/vitest.config.mts +++ b/packages/doc-gen/vitest.config.mts @@ -6,6 +6,7 @@ export default defineConfig({ }, test: { environment: 'node', + setupFiles: ['@primer/vitest-config/setup'], detectAsyncLeaks: true, }, }) diff --git a/packages/postcss-preset-primer/package.json b/packages/postcss-preset-primer/package.json index e4cd7dfec16..bc6ade9a93c 100644 --- a/packages/postcss-preset-primer/package.json +++ b/packages/postcss-preset-primer/package.json @@ -19,6 +19,7 @@ "postcss-preset-env": "^10.1.3" }, "devDependencies": { + "@primer/vitest-config": "^0.0.0", "@types/postcss-mixins": "9.0.5", "cssnano": "^7.0.7", "postcss": "^8.4.41", diff --git a/packages/postcss-preset-primer/vitest.config.ts b/packages/postcss-preset-primer/vitest.config.ts index 74213f595be..c699c977ba3 100644 --- a/packages/postcss-preset-primer/vitest.config.ts +++ b/packages/postcss-preset-primer/vitest.config.ts @@ -3,6 +3,7 @@ import {defineConfig} from 'vitest/config' export default defineConfig({ test: { environment: 'node', + setupFiles: ['@primer/vitest-config/setup'], detectAsyncLeaks: true, }, }) diff --git a/packages/react/package.json b/packages/react/package.json index 665179de202..23957c3569b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -108,6 +108,7 @@ "@figma/code-connect": "1.3.2", "@primer/css": "^21.5.1", "@primer/doc-gen": "^0.0.1", + "@primer/vitest-config": "^0.0.0", "@rollup/plugin-babel": "6.1.0", "@rollup/plugin-commonjs": "29.0.0", "@rollup/plugin-json": "6.1.0", diff --git a/packages/react/vitest.config.browser.mts b/packages/react/vitest.config.browser.mts index 6e931d4a364..eb99f39ef7b 100644 --- a/packages/react/vitest.config.browser.mts +++ b/packages/react/vitest.config.browser.mts @@ -31,6 +31,9 @@ export default defineConfig({ define: { __DEV__: true, 'process.env.CI': JSON.stringify(process.env.CI), + __PRIMER_TEST_FAIL_ON_CONSOLE__: JSON.stringify( + process.env.CI === 'true' || process.env.PRIMER_TEST_FAIL_ON_CONSOLE === 'true', + ), }, test: { name: '@primer/react', @@ -47,7 +50,7 @@ export default defineConfig({ 'src/__tests__/storybook.test.tsx', ], include: ['src/**/*.test.?(c|m)[jt]s?(x)'], - setupFiles: ['config/vitest/browser/setup.ts'], + setupFiles: ['@primer/vitest-config/setup', 'config/vitest/browser/setup.ts'], css: { include: [/.+/], }, diff --git a/packages/react/vitest.config.mts b/packages/react/vitest.config.mts index a9b57f621f0..df803a3c7c9 100644 --- a/packages/react/vitest.config.mts +++ b/packages/react/vitest.config.mts @@ -25,6 +25,7 @@ export default defineConfig({ name: '@primer/react (node)', include: ['src/__tests__/exports.test.ts', 'src/__tests__/storybook.test.tsx'], environment: 'node', + setupFiles: ['@primer/vitest-config/setup'], detectAsyncLeaks: true, }, }) diff --git a/packages/styled-react/package.json b/packages/styled-react/package.json index 46fb7377aa6..2c8a3e3933e 100644 --- a/packages/styled-react/package.json +++ b/packages/styled-react/package.json @@ -42,6 +42,7 @@ "@babel/preset-typescript": "^7.28.5", "@primer/primitives": "10.x || 11.x", "@primer/react": "^38.23.0", + "@primer/vitest-config": "^0.0.0", "@rollup/plugin-babel": "^6.1.0", "@storybook/react-vite": "^10.3.3", "@types/react": "18.3.11", diff --git a/packages/styled-react/vitest.config.browser.ts b/packages/styled-react/vitest.config.browser.ts index 098df99f86b..7ccee271a78 100644 --- a/packages/styled-react/vitest.config.browser.ts +++ b/packages/styled-react/vitest.config.browser.ts @@ -7,6 +7,9 @@ export default defineConfig({ plugins: [react()], define: { __DEV__: true, + __PRIMER_TEST_FAIL_ON_CONSOLE__: JSON.stringify( + process.env.CI === 'true' || process.env.PRIMER_TEST_FAIL_ON_CONSOLE === 'true', + ), }, resolve: { alias: [ @@ -27,7 +30,7 @@ export default defineConfig({ test: { name: '@primer/styled-react (browser)', include: ['src/**/*.browser.test.?(c|m)[jt]s?(x)'], - setupFiles: ['config/vitest/browser/setup.ts'], + setupFiles: ['@primer/vitest-config/setup', 'config/vitest/browser/setup.ts'], browser: { provider: playwright(), enabled: true, diff --git a/packages/styled-react/vitest.config.ts b/packages/styled-react/vitest.config.ts index b11e94140a3..efa0f74b637 100644 --- a/packages/styled-react/vitest.config.ts +++ b/packages/styled-react/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ name: '@primer/styled-react (node)', environment: 'node', exclude: ['src/**/*.browser.test.?(c|m)[jt]s?(x)'], + setupFiles: ['@primer/vitest-config/setup'], detectAsyncLeaks: true, }, }) diff --git a/packages/vitest-config/package.json b/packages/vitest-config/package.json new file mode 100644 index 00000000000..02ecae39880 --- /dev/null +++ b/packages/vitest-config/package.json @@ -0,0 +1,15 @@ +{ + "name": "@primer/vitest-config", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "type-check": "tsc --noEmit" + }, + "exports": { + "./setup": "./setup.ts" + }, + "dependencies": { + "vitest-fail-on-console": "^0.10.1" + } +} diff --git a/packages/vitest-config/setup.ts b/packages/vitest-config/setup.ts new file mode 100644 index 00000000000..6004e05d0b9 --- /dev/null +++ b/packages/vitest-config/setup.ts @@ -0,0 +1,22 @@ +import failOnConsole from 'vitest-fail-on-console' + +declare const __PRIMER_TEST_FAIL_ON_CONSOLE__: boolean | undefined + +const shouldFailOnConsole = + typeof __PRIMER_TEST_FAIL_ON_CONSOLE__ === 'boolean' + ? __PRIMER_TEST_FAIL_ON_CONSOLE__ + : typeof process !== 'undefined' && + (process.env.CI === 'true' || process.env.PRIMER_TEST_FAIL_ON_CONSOLE === 'true') + +if (shouldFailOnConsole) { + failOnConsole({ + silenceMessage: (message, methodName) => { + return ( + methodName === 'error' && + /^Warning: Unexpected return value from a callback ref in .+\. A callback ref should not return a function\./.test( + message, + ) + ) + }, + }) +} diff --git a/packages/vitest-config/tsconfig.json b/packages/vitest-config/tsconfig.json new file mode 100644 index 00000000000..d8e78f20278 --- /dev/null +++ b/packages/vitest-config/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["setup.ts"] +} From c94b7434fdcedb42506a223e51783b2af3c46a5a Mon Sep 17 00:00:00 2001 From: Josh Black Date: Fri, 15 May 2026 11:09:24 -0500 Subject: [PATCH 2/4] Fix Vitest console warnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react/src/ActionList/Group.test.tsx | 144 ++++++++++-------- .../react/src/ActionList/Heading.test.tsx | 43 ++++-- .../react/src/ActionMenu/ActionMenu.test.tsx | 16 +- .../AnchoredOverlay/AnchoredOverlay.test.tsx | 30 ++-- .../__tests__/Breadcrumbs.test.tsx | 44 ++++-- .../src/CheckboxGroup/CheckboxGroup.test.tsx | 9 +- .../src/CircleBadge/CircleBadge.test.tsx | 12 +- .../ConfirmationDialog.test.tsx | 3 + .../DataTable/__tests__/Pagination.test.tsx | 14 +- .../src/DataTable/__tests__/Table.test.tsx | 13 +- .../src/Details/__tests__/Details.test.tsx | 9 +- .../__tests__/FormControl.test.tsx | 20 ++- .../react/src/PageLayout/usePaneWidth.test.ts | 37 ++--- packages/react/src/Radio/Radio.test.tsx | 2 +- .../react/src/RadioGroup/RadioGroup.test.tsx | 9 +- .../SegmentedControl.test.tsx | 9 +- .../src/SelectPanel/SelectPanel.test.tsx | 7 +- .../src/TooltipV2/__tests__/Tooltip.test.tsx | 19 ++- packages/react/src/TopicTag/TopicTag.test.tsx | 5 +- .../src/UnderlineNav/UnderlineNav.test.tsx | 24 ++- .../__tests__/CheckboxOrRadioGroup.test.tsx | 9 +- .../__tests__/deprecated/ActionList.test.tsx | 12 +- .../__tests__/deprecated/ActionMenu.test.tsx | 3 + .../react/src/experimental/Tabs/Tabs.test.tsx | 108 ++++--------- .../UnderlinePanels/UnderlinePanels.test.tsx | 25 ++- .../src/hooks/__tests__/useSlots.test.tsx | 11 ++ 26 files changed, 383 insertions(+), 254 deletions(-) diff --git a/packages/react/src/ActionList/Group.test.tsx b/packages/react/src/ActionList/Group.test.tsx index 7d282da6296..d4b66c5f5f1 100644 --- a/packages/react/src/ActionList/Group.test.tsx +++ b/packages/react/src/ActionList/Group.test.tsx @@ -1,4 +1,4 @@ -import {describe, it, expect} from 'vitest' +import {describe, it, expect, vi} from 'vitest' import {render as HTMLRender} from '@testing-library/react' import {PlusIcon} from '@primer/octicons-react' import BaseStyles from '../BaseStyles' @@ -22,22 +22,23 @@ describe('ActionList.Group', () => { implementsClassName(ActionList.GroupHeading, classes.GroupHeading) it('should throw an error when ActionList.GroupHeading has an `as` prop when it is used within ActionMenu context', async () => { - expect(() => - HTMLRender( - - - Trigger - - - - Group Heading - - - - - , - ), - ).toThrow( + expect.hasAssertions() + expectRenderError( + () => + HTMLRender( + + + Trigger + + + + Group Heading + + + + + , + ), "Looks like you are trying to set a heading level to a menu role. Group headings for menu type action lists are for representational purposes, and rendered as divs. Therefore they don't need a heading level.", ) }) @@ -56,17 +57,18 @@ describe('ActionList.Group', () => { expect(heading).toHaveTextContent('Group Heading') }) it('should throw an error if ActionList.GroupHeading is used without an `as` prop when no role is specified (for list role)', async () => { - expect(() => - HTMLRender( - - Heading - - Group Heading - Item - - , - ), - ).toThrow( + expect.hasAssertions() + expectRenderError( + () => + HTMLRender( + + Heading + + Group Heading + Item + + , + ), "You are setting a heading for a list, that requires a heading level. Please use 'as' prop to set a proper heading level.", ) }) @@ -191,44 +193,60 @@ describe('ActionList.Group', () => { }) it('throws when GroupHeading.TrailingAction is used inside an ActionMenu (menu role) and the feature flag is enabled', () => { - expect(() => - HTMLRender( - - - - Trigger - - - - - Group Heading - - - - - - - - , - ), - ).toThrow(/can not be used inside an ActionList with an ARIA role of "menu"/) + expect.hasAssertions() + expectRenderError( + () => + HTMLRender( + + + + Trigger + + + + + Group Heading + + + + + + + + , + ), + /can not be used inside an ActionList with an ARIA role of "menu"/, + ) }) it('throws when GroupHeading.TrailingAction is used inside a listbox role and the feature flag is enabled', () => { - expect(() => - HTMLRender( - - - - - Group Heading - - - - - , - ), - ).toThrow(/can not be used inside an ActionList with an ARIA role of "listbox"/) + expect.hasAssertions() + expectRenderError( + () => + HTMLRender( + + + + + Group Heading + + + + + , + ), + /can not be used inside an ActionList with an ARIA role of "listbox"/, + ) }) }) }) + +function expectRenderError(callback: () => void, error: string | RegExp) { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(callback).toThrow(error) + expect(consoleError).toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } +} diff --git a/packages/react/src/ActionList/Heading.test.tsx b/packages/react/src/ActionList/Heading.test.tsx index 316d4bad06d..c5c1ea24135 100644 --- a/packages/react/src/ActionList/Heading.test.tsx +++ b/packages/react/src/ActionList/Heading.test.tsx @@ -1,4 +1,4 @@ -import {describe, it, expect} from 'vitest' +import {describe, it, expect, vi} from 'vitest' import {render as HTMLRender} from '@testing-library/react' import BaseStyles from '../BaseStyles' import {ActionList} from '.' @@ -42,22 +42,33 @@ describe('ActionList.Heading', () => { }) it('should throw an error when ActionList.Heading is used within ActionMenu context', async () => { - expect(() => - HTMLRender( - - - Trigger - - - Heading - Item - - - - , - ), - ).toThrow( + expect.hasAssertions() + expectRenderError( + () => + HTMLRender( + + + Trigger + + + Heading + Item + + + + , + ), "ActionList.Heading shouldn't be used within an ActionMenu container. Menus are labelled by the menu button's name.", ) }) }) + +function expectRenderError(callback: () => void, error: string | RegExp) { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(callback).toThrow(error) + expect(consoleError).toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } +} diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx index 09303b7923b..2533711a6f4 100644 --- a/packages/react/src/ActionMenu/ActionMenu.test.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx @@ -340,9 +340,7 @@ describe('ActionMenu', () => { const button = component.getByRole('button') const user = userEvent.setup() - await act(async () => { - await user.click(button) - }) + await user.click(button) expect(component.queryByRole('menu')).toBeInTheDocument() const menuItems = component.getAllByRole('menuitem') @@ -355,13 +353,11 @@ describe('ActionMenu', () => { await user.keyboard('{ArrowDown}') expect(menuItems[1]).toEqual(document.activeElement) - await act(async () => { - // TODO: Removed one ArrowDown to account for the focus trap starting at the second element - // await user.keyboard('{ArrowDown}') - await user.keyboard('{ArrowDown}') - await user.keyboard('{ArrowDown}') - await user.keyboard('{ArrowDown}') - }) + // TODO: Removed one ArrowDown to account for the focus trap starting at the second element + // await user.keyboard('{ArrowDown}') + await user.keyboard('{ArrowDown}') + await user.keyboard('{ArrowDown}') + await user.keyboard('{ArrowDown}') expect(menuItems[menuItems.length - 1]).toEqual(document.activeElement) // last elememt await user.keyboard('{ArrowDown}') diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index 255c771c6b3..ac5698f6951 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -1,7 +1,7 @@ import {act, createRef, useCallback, useRef, useState} from 'react' import {describe, expect, it, vi} from 'vitest' -import {render} from '@testing-library/react' -import {userEvent} from 'vitest/browser' +import {fireEvent, render} from '@testing-library/react' +import userEvent from '@testing-library/user-event' import {AnchoredOverlay} from '../AnchoredOverlay' import {Button} from '../Button' import BaseStyles from '../BaseStyles' @@ -104,9 +104,7 @@ describe.each([true, false])( />, ) const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')! - await act(async () => { - await userEvent.click(anchor) - }) + await userEvent.click(anchor) expect(mockOpenCallback).toHaveBeenCalledTimes(1) expect(mockOpenCallback).toHaveBeenCalledWith('anchor-click') @@ -124,9 +122,7 @@ describe.each([true, false])( />, ) const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')! - await act(async () => { - await userEvent.type(anchor, '{Space}') - }) + fireEvent.keyDown(anchor, {key: ' ', code: 'Space'}) expect(mockOpenCallback).toHaveBeenCalledTimes(1) expect(mockOpenCallback).toHaveBeenCalledWith('anchor-key-press') @@ -144,9 +140,7 @@ describe.each([true, false])( withCSSAnchorPositioningFeatureFlag={withCSSAnchorPositioningFeatureFlag} />, ) - await act(async () => { - await userEvent.click(anchoredOverlay.baseElement) - }) + await userEvent.click(anchoredOverlay.baseElement) expect(mockOpenCallback).toHaveBeenCalledTimes(0) expect(mockCloseCallback).toHaveBeenCalledTimes(1) @@ -166,9 +160,7 @@ describe.each([true, false])( />, ) - await act(async () => { - await userEvent.keyboard('{Escape}') - }) + await userEvent.keyboard('{Escape}') expect(mockOpenCallback).toHaveBeenCalledTimes(0) expect(mockCloseCallback).toHaveBeenCalledTimes(1) @@ -185,9 +177,7 @@ describe.each([true, false])( />, ) - await act(async () => { - await userEvent.keyboard('{Escape}') - }) + await userEvent.keyboard('{Escape}') expect(mockPositionChangeCallback).toHaveBeenCalled() expect(mockPositionChangeCallback).toHaveBeenCalledWith({ @@ -309,6 +299,7 @@ describe('AnchoredOverlay feature flag specific behavior', () => { }) it('should set popover="manual" on overlay when renderAs is "popover"', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const {baseElement} = render( @@ -317,9 +308,12 @@ describe('AnchoredOverlay feature flag specific behavior', () => { const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') expect(overlay).toHaveAttribute('popover', 'manual') + expect(consoleError).toHaveBeenCalled() + consoleError.mockRestore() }) it('should set popovertarget on anchor when renderAs is "popover"', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const {baseElement} = render( @@ -330,6 +324,8 @@ describe('AnchoredOverlay feature flag specific behavior', () => { const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') expect(anchor).toHaveAttribute('popovertarget') expect(anchor!.getAttribute('popovertarget')).toBe(overlay!.getAttribute('id')) + expect(consoleError).toHaveBeenCalled() + consoleError.mockRestore() }) it('should not set popover attribute on overlay when renderAs is "portal"', () => { diff --git a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx index 25dcbc5f8fc..76c221ff46f 100644 --- a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx @@ -1,5 +1,5 @@ import Breadcrumbs from '..' -import {render as HTMLRender, screen, waitFor, within} from '@testing-library/react' +import {act, render as HTMLRender, screen, waitFor, within} from '@testing-library/react' import {describe, expect, it, vi} from 'vitest' import userEvent from '@testing-library/user-event' import {FeatureFlags} from '../../FeatureFlags' @@ -243,30 +243,34 @@ describe('Breadcrumbs', () => { ) expect(resizeCallback).toBeDefined() + const callback = resizeCallback + if (!callback) { + throw new Error('ResizeObserver callback was not registered') + } // Initially should show overflow menu for >5 items expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument() // Simulate a wide container resize - if (resizeCallback) { - resizeCallback([ + act(() => { + callback([ { contentRect: {width: 800, height: 40}, } as ResizeObserverEntry, ]) - } + }) // Should still have overflow menu for 6 items (>5 rule) expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument() // Simulate a narrow container resize - if (resizeCallback) { - resizeCallback([ + act(() => { + callback([ { contentRect: {width: 250, height: 40}, } as ResizeObserverEntry, ]) - } + }) // Should maintain overflow menu for narrow container expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument() @@ -303,6 +307,10 @@ describe('Breadcrumbs', () => { ) expect(resizeCallback).toBeDefined() + const callback = resizeCallback + if (!callback) { + throw new Error('ResizeObserver callback was not registered') + } // Initially should show overflow menu for >5 items const menuButton = screen.getByRole('button', {name: /more breadcrumb items/i}) @@ -327,29 +335,29 @@ describe('Breadcrumbs', () => { // Close menu by clicking outside await user.click(document.body) await waitFor(() => { - expect + expect(menuButton).toHaveAttribute('aria-expanded', 'false') }) // Simulate a very narrow container resize that would affect overflow calculation - if (resizeCallback) { - resizeCallback([ + act(() => { + callback([ { contentRect: {width: 200, height: 40}, } as ResizeObserverEntry, ]) - } + }) // Menu button should still be present expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument() // Simulate a very wide container resize - if (resizeCallback) { - resizeCallback([ + act(() => { + callback([ { contentRect: {width: 1200, height: 40}, } as ResizeObserverEntry, ]) - } + }) // Menu button should still be present (7 items > 5) expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument() @@ -506,7 +514,9 @@ describe('Breadcrumbs', () => { const menuButton = screen.getByRole('button', {name: /more breadcrumb items/i}) // Focus the menu button - menuButton.focus() + act(() => { + menuButton.focus() + }) expect(menuButton).toHaveFocus() // Open menu with Enter key @@ -521,7 +531,9 @@ describe('Breadcrumbs', () => { await user.keyboard('{Escape}') // Verify focus returns to button - expect(menuButton).toHaveFocus() + await waitFor(() => { + expect(menuButton).toHaveFocus() + }) }) }) diff --git a/packages/react/src/CheckboxGroup/CheckboxGroup.test.tsx b/packages/react/src/CheckboxGroup/CheckboxGroup.test.tsx index f752ae2d5c9..136b221e09f 100644 --- a/packages/react/src/CheckboxGroup/CheckboxGroup.test.tsx +++ b/packages/react/src/CheckboxGroup/CheckboxGroup.test.tsx @@ -6,7 +6,14 @@ import {implementsClassName} from '../utils/testing' import classes from '../internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroup.module.css' describe('CheckboxGroup', () => { - implementsClassName(CheckboxGroup, classes.GroupFieldset) + implementsClassName( + props => ( + + Choices + + ), + classes.GroupFieldset, + ) implementsClassName(CheckboxGroup.Caption, classes.CheckboxOrRadioGroupCaption) implementsClassName(CheckboxGroup.Label, classes.RadioGroupLabel) const mockWarningFn = vi.fn() diff --git a/packages/react/src/CircleBadge/CircleBadge.test.tsx b/packages/react/src/CircleBadge/CircleBadge.test.tsx index 32284a3d06d..c73c50ca164 100644 --- a/packages/react/src/CircleBadge/CircleBadge.test.tsx +++ b/packages/react/src/CircleBadge/CircleBadge.test.tsx @@ -1,7 +1,7 @@ import CircleBadge from './CircleBadge' import {CheckIcon} from '@primer/octicons-react' import {render as HTMLRender} from '@testing-library/react' -import {describe, expect, it} from 'vitest' +import {describe, expect, it, vi} from 'vitest' import {implementsClassName} from '../utils/testing' import classes from './CircleBadge.module.css' @@ -16,8 +16,14 @@ describe('CircleBadge', () => { }) it('respects the inline prop', () => { - const {container} = HTMLRender() - expect(container.firstChild).toMatchSnapshot() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const {container} = HTMLRender() + expect(container.firstChild).toMatchSnapshot() + expect(consoleError).toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } }) it('respects the variant prop', () => { diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx index eeb91d7e9fd..0ecbd1990f3 100644 --- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx +++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx @@ -152,6 +152,7 @@ describe('ConfirmationDialog', () => { }) it('supports nested `focusTrap`s', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const {getByText, getByRole} = render() fireEvent.click(getByText('Show menu')) @@ -159,6 +160,8 @@ describe('ConfirmationDialog', () => { expect(getByRole('button', {name: 'Primary'})).toEqual(document.activeElement) expect(getByRole('button', {name: 'Secondary'})).not.toEqual(document.activeElement) + expect(consoleError).toHaveBeenCalled() + consoleError.mockRestore() }) it('accepts a className prop', async () => { diff --git a/packages/react/src/DataTable/__tests__/Pagination.test.tsx b/packages/react/src/DataTable/__tests__/Pagination.test.tsx index 90432a80063..0f4a8925adc 100644 --- a/packages/react/src/DataTable/__tests__/Pagination.test.tsx +++ b/packages/react/src/DataTable/__tests__/Pagination.test.tsx @@ -1,7 +1,7 @@ import {page} from 'vitest/browser' import {beforeEach, describe, expect, it, vi} from 'vitest' import {Pagination} from '../Pagination' -import {render, screen} from '@testing-library/react' +import {render, screen, waitFor} from '@testing-library/react' import userEvent from '@testing-library/user-event' describe('Table.Pagination', () => { @@ -103,7 +103,9 @@ describe('Table.Pagination', () => { rerender( , ) - expect(getPageRange()).toEqual('11 through 15 of 300') + await waitFor(() => { + expect(getPageRange()).toEqual('11 through 15 of 300') + }) expect(getCurrentPage()).toEqual(getPage(2)) expect(getInvalidPages()).toHaveLength(0) expect(onChange).toHaveBeenCalledWith({ @@ -296,7 +298,9 @@ describe('Table.Pagination', () => { rerender( , ) - expect(getPageRange()).toEqual('1 through 5 of 300') + await waitFor(() => { + expect(getPageRange()).toEqual('1 through 5 of 300') + }) expect(getCurrentPage()).toEqual(getPage(0)) expect(getInvalidPages()).toHaveLength(0) expect(onChange).toHaveBeenCalledWith({ @@ -361,7 +365,9 @@ describe('Table.Pagination', () => { rerender( , ) - expect(getPageRange()).toEqual('1 through 5 of 300') + await waitFor(() => { + expect(getPageRange()).toEqual('1 through 5 of 300') + }) expect(getFirstPage()).toEqual(getCurrentPage()) expect(getInvalidPages()).toHaveLength(0) expect(onChange).toHaveBeenCalledWith({ diff --git a/packages/react/src/DataTable/__tests__/Table.test.tsx b/packages/react/src/DataTable/__tests__/Table.test.tsx index d7c5d126472..99e76bb43e2 100644 --- a/packages/react/src/DataTable/__tests__/Table.test.tsx +++ b/packages/react/src/DataTable/__tests__/Table.test.tsx @@ -219,7 +219,18 @@ describe('Table', () => { }) describe('Table.Cell', () => { - implementsClassName(Table.Cell, classes.TableCell) + implementsClassName( + props => ( + + + + Cell + + +
+ ), + classes.TableCell, + ) it('should set the element to a when `scope` is defined', () => { render( diff --git a/packages/react/src/Details/__tests__/Details.test.tsx b/packages/react/src/Details/__tests__/Details.test.tsx index 61afbccec04..964b18fff63 100644 --- a/packages/react/src/Details/__tests__/Details.test.tsx +++ b/packages/react/src/Details/__tests__/Details.test.tsx @@ -7,7 +7,14 @@ import {implementsClassName} from '../../utils/testing' import classes from '../Details.module.css' describe('Details', () => { - implementsClassName(Details, classes.Details) + implementsClassName( + props => ( +
+ Summary +
+ ), + classes.Details, + ) implementsClassName(Details.Summary) it('Toggles when you click outside', async () => { const Component = () => { diff --git a/packages/react/src/FormControl/__tests__/FormControl.test.tsx b/packages/react/src/FormControl/__tests__/FormControl.test.tsx index bd3141abdd6..7032dd6952d 100644 --- a/packages/react/src/FormControl/__tests__/FormControl.test.tsx +++ b/packages/react/src/FormControl/__tests__/FormControl.test.tsx @@ -59,8 +59,24 @@ const WrappedValidationComponent: FCWithSlotMarker = () => ( WrappedValidationComponent.__SLOT__ = FormControl.Validation.__SLOT__ describe('FormControl', () => { - implementsClassName(FormControl, classes.ControlVerticalLayout) - implementsClassName(props => , classes.ControlHorizontalLayout) + implementsClassName( + props => ( + + {LABEL_TEXT} + + + ), + classes.ControlVerticalLayout, + ) + implementsClassName( + props => ( + + {LABEL_TEXT} + + + ), + classes.ControlHorizontalLayout, + ) implementsClassName(FormControl.Caption, captionClasses.Caption) implementsClassName(FormControl.Label, inputClasses.Label) diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index dca2ac2adb8..bb245aa87d6 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -735,12 +735,11 @@ describe('usePaneWidth', () => { // Shrink viewport (crosses 1280 breakpoint, diff switches to 511) vi.stubGlobal('innerWidth', 1000) - // Fire resize - with throttle, first update happens immediately (if THROTTLE_MS passed) - window.dispatchEvent(new Event('resize')) - // Since Date.now() starts at 0 and lastUpdateTime is 0, first update should happen immediately // but it's in rAF, so we need to advance through rAF await act(async () => { + // Fire resize - with throttle, first update happens immediately (if THROTTLE_MS passed) + window.dispatchEvent(new Event('resize')) await vi.runAllTimersAsync() }) @@ -771,11 +770,10 @@ describe('usePaneWidth', () => { // Shrink viewport (crosses 1280 breakpoint, diff switches to 511) vi.stubGlobal('innerWidth', 900) - // Fire resize - with throttle, update happens via rAF - window.dispatchEvent(new Event('resize')) - // Wait for rAF to complete await act(async () => { + // Fire resize - with throttle, update happens via rAF + window.dispatchEvent(new Event('resize')) await vi.runAllTimersAsync() }) @@ -807,10 +805,10 @@ describe('usePaneWidth', () => { // Fire resize events rapidly vi.stubGlobal('innerWidth', 1100) - window.dispatchEvent(new Event('resize')) // With throttle, CSS should update immediately or via rAF await act(async () => { + window.dispatchEvent(new Event('resize')) await vi.runAllTimersAsync() }) @@ -821,13 +819,12 @@ describe('usePaneWidth', () => { setPropertySpy.mockClear() // Fire more resize events rapidly (within throttle window) - for (let i = 0; i < 3; i++) { - vi.stubGlobal('innerWidth', 1000 - i * 50) - window.dispatchEvent(new Event('resize')) - } - // Should schedule via rAF await act(async () => { + for (let i = 0; i < 3; i++) { + vi.stubGlobal('innerWidth', 1000 - i * 50) + window.dispatchEvent(new Event('resize')) + } await vi.runAllTimersAsync() }) @@ -858,10 +855,10 @@ describe('usePaneWidth', () => { // Shrink viewport (crosses 1280 breakpoint, diff switches to 511) vi.stubGlobal('innerWidth', 800) - window.dispatchEvent(new Event('resize')) // After throttle (via rAF), state updated via startTransition await act(async () => { + window.dispatchEvent(new Event('resize')) await vi.runAllTimersAsync() }) @@ -963,7 +960,9 @@ describe('usePaneWidth', () => { // Fire resize vi.stubGlobal('innerWidth', 1000) - window.dispatchEvent(new Event('resize')) + await act(async () => { + window.dispatchEvent(new Event('resize')) + }) // Attribute should be applied immediately on first resize expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true) @@ -971,7 +970,9 @@ describe('usePaneWidth', () => { // Fire another resize event immediately (simulating continuous resize) vi.stubGlobal('innerWidth', 900) - window.dispatchEvent(new Event('resize')) + await act(async () => { + window.dispatchEvent(new Event('resize')) + }) // Attribute should still be present (containment stays on during continuous resize) expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true) @@ -1006,7 +1007,9 @@ describe('usePaneWidth', () => { // Fire resize vi.stubGlobal('innerWidth', 1000) - window.dispatchEvent(new Event('resize')) + await act(async () => { + window.dispatchEvent(new Event('resize')) + }) // Attribute should be applied expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true) @@ -1044,9 +1047,9 @@ describe('usePaneWidth', () => { // Shrink viewport (crosses 1280 breakpoint, diff switches to 511) vi.stubGlobal('innerWidth', 800) - window.dispatchEvent(new Event('resize')) await act(async () => { + window.dispatchEvent(new Event('resize')) await vi.runAllTimersAsync() }) diff --git a/packages/react/src/Radio/Radio.test.tsx b/packages/react/src/Radio/Radio.test.tsx index 854eb350790..206dd761178 100644 --- a/packages/react/src/Radio/Radio.test.tsx +++ b/packages/react/src/Radio/Radio.test.tsx @@ -5,11 +5,11 @@ import {implementsClassName} from '../utils/testing' import classes from './Radio.module.css' describe('Radio', () => { - implementsClassName(Radio, classes.Radio) const defaultProps = { name: 'mock', value: 'mock value', } + implementsClassName(props => , classes.Radio) beforeEach(() => { vi.resetAllMocks() diff --git a/packages/react/src/RadioGroup/RadioGroup.test.tsx b/packages/react/src/RadioGroup/RadioGroup.test.tsx index 791e9e5188c..2851cd0633e 100644 --- a/packages/react/src/RadioGroup/RadioGroup.test.tsx +++ b/packages/react/src/RadioGroup/RadioGroup.test.tsx @@ -6,7 +6,14 @@ import {implementsClassName} from '../utils/testing' import classes from '../internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroup.module.css' describe('RadioGroup', () => { - implementsClassName(RadioGroup, classes.GroupFieldset) + implementsClassName( + props => ( + + Choices + + ), + classes.GroupFieldset, + ) implementsClassName(RadioGroup.Caption, classes.CheckboxOrRadioGroupCaption) implementsClassName(RadioGroup.Label, classes.RadioGroupLabel) const mockWarningFn = vi.fn() diff --git a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx index 6a637d1ba30..f1ab77a370b 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx @@ -32,7 +32,14 @@ const segmentData = [ ] describe('SegmentedControl', () => { - implementsClassName(SegmentedControl, classes.SegmentedControl) + implementsClassName( + props => ( + + Preview + + ), + classes.SegmentedControl, + ) it('renders with a selected segment - controlled', () => { const {getByText} = render( diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index c1d71b154c7..f4f498267db 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -1671,11 +1671,10 @@ for (const usingRemoveActiveDescendant of [false, true]) { const input = screen.getByPlaceholderText('Filter items') const options = screen.getAllByRole('option') - // Wait a tick for the effect to run - await new Promise(resolve => setTimeout(resolve, 0)) - // aria-activedescendant should be set to the first item - expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + await waitFor(() => { + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + }) }) it('should not set aria-activedescendant on mouse hover until after first interaction when setInitialFocus is true', async () => { diff --git a/packages/react/src/TooltipV2/__tests__/Tooltip.test.tsx b/packages/react/src/TooltipV2/__tests__/Tooltip.test.tsx index a2755af56f0..b2a7d71e0cb 100644 --- a/packages/react/src/TooltipV2/__tests__/Tooltip.test.tsx +++ b/packages/react/src/TooltipV2/__tests__/Tooltip.test.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import {describe, expect, it} from 'vitest' +import {describe, expect, it, vi} from 'vitest' import type {TooltipProps} from '../Tooltip' import {Tooltip} from '../Tooltip' import {render as HTMLRender} from '@testing-library/react' @@ -125,15 +125,14 @@ describe('Tooltip', () => { expect(triggerEL.getAttribute('aria-describedby')).toContain('custom-tooltip-id') }) it('should throw an error if the trigger element is disabled', () => { - expect(() => { + expect.hasAssertions() + expectRenderError(() => { HTMLRender( , ) - }).toThrow( - 'The `Tooltip` component expects a single React element that contains interactive content. Consider using a `