diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2321ea1713a..b0d11b2fe1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,8 @@ jobs: run: npm run lint:md - name: Lint npm packages run: npx turbo lint:npm + - name: Check className test coverage + run: npm run test:classname-coverage test: runs-on: ubuntu-latest diff --git a/package.json b/package.json index 80e139dbe68..43df6ad831d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test": "vitest", "test:type-check": "tsc --noEmit", "test:update": "npm run test -- -u", + "test:classname-coverage": "node script/check-classname-tests.mjs", "type-check": "tsc --noEmit && turbo type-check", "release": "npm run build && changeset publish", "reset": "script/reset", diff --git a/packages/react/src/ActionBar/ActionBar.test.tsx b/packages/react/src/ActionBar/ActionBar.test.tsx index ea038c9c17b..05029b2e853 100644 --- a/packages/react/src/ActionBar/ActionBar.test.tsx +++ b/packages/react/src/ActionBar/ActionBar.test.tsx @@ -4,8 +4,11 @@ import userEvent from '@testing-library/user-event' import ActionBar from './' import {BoldIcon, ItalicIcon, CodeIcon} from '@primer/octicons-react' import {useState} from 'react' +import {implementsClassName} from '../utils/testing' +import classes from './ActionBar.module.css' describe('ActionBar', () => { + implementsClassName(ActionBar, classes.Nav) afterEach(() => { vi.clearAllMocks() }) diff --git a/packages/react/src/ActionList/ActionList.test.tsx b/packages/react/src/ActionList/ActionList.test.tsx index 0c4361e5b87..c7d58780488 100644 --- a/packages/react/src/ActionList/ActionList.test.tsx +++ b/packages/react/src/ActionList/ActionList.test.tsx @@ -2,8 +2,15 @@ import {describe, it, expect, vi} from 'vitest' import {render as HTMLRender} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {ActionList} from '.' +import {implementsClassName} from '../utils/testing' +import classes from './ActionList.module.css' describe('ActionList', () => { + implementsClassName(ActionList, classes.ActionList) + implementsClassName(ActionList.LeadingVisual, classes.LeadingVisual) + implementsClassName(ActionList.TrailingVisual, classes.TrailingVisual) + implementsClassName(ActionList.TrailingAction, classes.TrailingAction) + implementsClassName(ActionList.Divider, classes.Divider) it('should warn when selected is provided without a selectionVariant on parent', async () => { // we expect console.warn to be called, so we spy on that in the test const spy = vi.spyOn(console, 'warn').mockImplementation(() => vi.fn()) @@ -59,17 +66,6 @@ describe('ActionList', () => { expect(document.activeElement).toHaveTextContent('Option 4') }) - it('should support a custom `className` on the outermost element', () => { - const Element = () => { - return ( - - Item - - ) - } - expect(HTMLRender().container.querySelector('ul')).toHaveClass('test-class-name') - }) - it('divider should support a custom `className`', () => { const Element = () => { return ( diff --git a/packages/react/src/ActionList/Description.test.tsx b/packages/react/src/ActionList/Description.test.tsx index 201e72be1fb..a0aabe3bcd5 100644 --- a/packages/react/src/ActionList/Description.test.tsx +++ b/packages/react/src/ActionList/Description.test.tsx @@ -1,8 +1,11 @@ import {expect, it, describe} from 'vitest' import {render as HTMLRender} from '@testing-library/react' import {ActionList} from '.' +import {implementsClassName} from '../utils/testing' +import classes from './ActionList.module.css' describe('ActionList.Description', () => { + implementsClassName(ActionList.Description, classes.Description) it('should render the description as inline without truncation by default', () => { const {getByText} = HTMLRender( diff --git a/packages/react/src/ActionList/Group.test.tsx b/packages/react/src/ActionList/Group.test.tsx index 2e3fcc6b9d4..23e14664661 100644 --- a/packages/react/src/ActionList/Group.test.tsx +++ b/packages/react/src/ActionList/Group.test.tsx @@ -3,8 +3,22 @@ import {render as HTMLRender} from '@testing-library/react' import BaseStyles from '../BaseStyles' import {ActionList} from '.' import {ActionMenu} from '..' +import {implementsClassName} from '../utils/testing' +import classes from './Group.module.css' describe('ActionList.Group', () => { + implementsClassName( + props => ( + + + item + + + ), + classes.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( @@ -122,19 +136,4 @@ describe('ActionList.Group', () => { const list = container.querySelector(`li[data-test-id='ActionList.Group'] > ul`) expect(list).toHaveAttribute('aria-label', 'Animals') }) - - it('should support a custom `className` on the outermost element', () => { - const Element = () => { - return ( - - - - Test - - - - ) - } - expect(HTMLRender().container.querySelector('h2')).toHaveClass('test-class-name') - }) }) diff --git a/packages/react/src/ActionList/Heading.test.tsx b/packages/react/src/ActionList/Heading.test.tsx index 8227f229630..316d4bad06d 100644 --- a/packages/react/src/ActionList/Heading.test.tsx +++ b/packages/react/src/ActionList/Heading.test.tsx @@ -3,8 +3,21 @@ import {render as HTMLRender} from '@testing-library/react' import BaseStyles from '../BaseStyles' import {ActionList} from '.' import {ActionMenu} from '..' +import {implementsClassName} from '../utils/testing' +import classes from './Heading.module.css' describe('ActionList.Heading', () => { + implementsClassName( + props => ( + + + Heading + + + ), + classes.ActionListHeader, + ) + it('should render the ActionList.Heading component as a heading with the given heading level', async () => { const container = HTMLRender( @@ -47,15 +60,4 @@ describe('ActionList.Heading', () => { "ActionList.Heading shouldn't be used within an ActionMenu container. Menus are labelled by the menu button's name.", ) }) - - it('should support a custom `className` on the outermost element', () => { - const actionList = HTMLRender( - - - Filter by - - , - ) - expect(actionList.container.querySelector('h2')).toHaveClass('test-class-name') - }) }) diff --git a/packages/react/src/ActionList/Item.test.tsx b/packages/react/src/ActionList/Item.test.tsx index 495b9a52b5e..b46fa6f1470 100644 --- a/packages/react/src/ActionList/Item.test.tsx +++ b/packages/react/src/ActionList/Item.test.tsx @@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event' import React, {type JSX} from 'react' import {ActionList} from '.' import {BookIcon} from '@primer/octicons-react' +import {implementsClassName} from '../utils/testing' +import classes from './ActionList.module.css' function SimpleActionList(): JSX.Element { return ( @@ -60,6 +62,8 @@ function SingleSelectListStory(): JSX.Element { } describe('ActionList.Item', () => { + implementsClassName(ActionList.Item, classes.ActionListItem) + implementsClassName(ActionList.LinkItem) it('should have aria-keyshortcuts applied to the correct element', async () => { const {container} = HTMLRender() const linkOptions = await waitFor(() => container.querySelectorAll('a')) diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx index d1c2971b327..0be609c68e8 100644 --- a/packages/react/src/ActionMenu/ActionMenu.test.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx @@ -11,6 +11,7 @@ import {MixedSelection} from '../ActionMenu/ActionMenu.examples.stories' import {SearchIcon, KebabHorizontalIcon} from '@primer/octicons-react' import type {JSX} from 'react' +import {implementsClassName} from '../utils/testing' function Example(): JSX.Element { return ( @@ -120,6 +121,8 @@ function ExampleWithSubmenus(): JSX.Element { } describe('ActionMenu', () => { + implementsClassName(ActionMenu.Button) + it('should open Menu on MenuButton click', async () => { const component = HTMLRender() const button = component.getByRole('button') diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index 18533837f3e..279bcc112d1 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -6,11 +6,16 @@ import {AnchoredOverlay} from '../AnchoredOverlay' import {Button} from '../Button' import BaseStyles from '../BaseStyles' import type {AnchorPosition} from '@primer/behaviors' +import {implementsClassName} from '../utils/testing' + +import overlayClasses from '../Overlay/Overlay.module.css' + type TestComponentSettings = { initiallyOpen?: boolean onOpenCallback?: (gesture: string) => void onCloseCallback?: (gesture: string) => void onPositionChange?: ({position}: {position: AnchorPosition}) => void + className?: string } const AnchoredOverlayTestComponent = ({ @@ -18,6 +23,7 @@ const AnchoredOverlayTestComponent = ({ onOpenCallback, onCloseCallback, onPositionChange, + className, }: TestComponentSettings = {}) => { const [open, setOpen] = useState(initiallyOpen) const onOpen = useCallback( @@ -42,6 +48,7 @@ const AnchoredOverlayTestComponent = ({ onClose={onClose} renderAnchor={props => } onPositionChange={onPositionChange} + className={className} > @@ -50,6 +57,7 @@ const AnchoredOverlayTestComponent = ({ } describe('AnchoredOverlay', () => { + implementsClassName(props => , overlayClasses.Overlay) it('should call onOpen when the anchor is clicked', async () => { const mockOpenCallback = vi.fn() const mockCloseCallback = vi.fn() diff --git a/packages/react/src/Autocomplete/Autocomplete.test.tsx b/packages/react/src/Autocomplete/Autocomplete.test.tsx index 5b97da8cdc9..ea8c2a501df 100644 --- a/packages/react/src/Autocomplete/Autocomplete.test.tsx +++ b/packages/react/src/Autocomplete/Autocomplete.test.tsx @@ -6,6 +6,8 @@ import type {AutocompleteInputProps} from '../Autocomplete' import Autocomplete from '../Autocomplete' import type {AutocompleteMenuInternalProps, AutocompleteMenuItem} from '../Autocomplete/AutocompleteMenu' import BaseStyles from '../BaseStyles' +import {implementsClassName} from '../utils/testing' +import classes from './AutocompleteOverlay.module.css' const mockItems = [ {text: 'zero', id: '0'}, @@ -47,6 +49,11 @@ const LabelledAutocomplete = ({ describe('Autocomplete', () => { describe('Autocomplete.Input', () => { + implementsClassName(props => ( + + + + )) it('calls onChange', async () => { const user = userEvent.setup() const onChangeMock = vi.fn() @@ -214,15 +221,6 @@ describe('Autocomplete', () => { expect(getByDisplayValue('0')).toBeDefined() }) - - it('should support `className` on the outermost element', () => { - const Element = () => ( - - - - ) - expect(render().container.firstChild).toHaveClass('test-class-name') - }) }) describe('Autocomplete.Menu', () => { @@ -444,23 +442,20 @@ describe('Autocomplete', () => { }) }) - describe('Autocomplete.Overlay', () => { - it('should support `className` on the outermost element', async () => { - const Element = ({className}: {className: string}) => ( + // TODO: Enable once className override bug is fixed in Autocomplete.Overlay, also remember to remove from ignore list on script/check-classname-tests.mjs + // eslint-disable-next-line vitest/no-disabled-tests + describe.skip('Autocomplete.Overlay', () => { + implementsClassName( + props => ( - + hi - ) - const {container: elementContainer, getByRole} = render() - const inputNode = getByRole('combobox') - await userEvent.click(inputNode) - await userEvent.keyboard('{ArrowDown}') - // overlay is a sibling of elementContainer - expect(elementContainer.parentElement?.querySelectorAll('.test-class-name')).toHaveLength(1) - }) + ), + classes.Overlay, + ) }) describe('null context', () => { diff --git a/packages/react/src/Avatar/Avatar.test.tsx b/packages/react/src/Avatar/Avatar.test.tsx index 113b656d470..536c92f1710 100644 --- a/packages/react/src/Avatar/Avatar.test.tsx +++ b/packages/react/src/Avatar/Avatar.test.tsx @@ -1,12 +1,11 @@ import {describe, expect, it} from 'vitest' import {render, screen} from '@testing-library/react' import Avatar from '../Avatar' +import {implementsClassName} from '../utils/testing' +import classes from './Avatar.module.css' describe('Avatar', () => { - it('should support `className` on the outermost element', () => { - const Element = () => - expect(render().container.firstChild).toHaveClass('test-class-name') - }) + implementsClassName(Avatar, classes.Avatar) it('renders small by default', () => { const size = 20 diff --git a/packages/react/src/AvatarStack/AvatarStack.test.tsx b/packages/react/src/AvatarStack/AvatarStack.test.tsx index 27522df0167..81bc50838e6 100644 --- a/packages/react/src/AvatarStack/AvatarStack.test.tsx +++ b/packages/react/src/AvatarStack/AvatarStack.test.tsx @@ -1,6 +1,8 @@ import {describe, expect, it} from 'vitest' import {render} from '@testing-library/react' import {AvatarStack} from '..' +import {implementsClassName} from '../utils/testing' +import classes from './AvatarStack.module.css' const avatarComp = ( @@ -21,17 +23,7 @@ const rightAvatarComp = ( ) describe('AvatarStack', () => { - it('should support `className` on the outermost element', () => { - const Element = () => ( - - - - - - - ) - expect(render().container.firstChild).toHaveClass('test-class-name') - }) + implementsClassName(AvatarStack, classes.AvatarStack) it('respects alignRight props', () => { const {container} = render(rightAvatarComp) diff --git a/packages/react/src/Banner/Banner.test.tsx b/packages/react/src/Banner/Banner.test.tsx index bbce0ca0808..1e6bebe38ce 100644 --- a/packages/react/src/Banner/Banner.test.tsx +++ b/packages/react/src/Banner/Banner.test.tsx @@ -2,19 +2,18 @@ import {describe, expect, it, vi} from 'vitest' import {render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {Banner} from '../Banner' +import {implementsClassName} from '../utils/testing' +import classes from './Banner.module.css' describe('Banner', () => { + implementsClassName(props => , classes.Banner) + it('should render as a region element', () => { render() expect(screen.getByRole('region', {name: 'Information'})).toBeInTheDocument() expect(screen.getByRole('heading', {name: 'test'})).toBeInTheDocument() }) - it('should support a custom `className` on the outermost element', () => { - const Element = () => - expect(render().container.firstChild).toHaveClass('test-class-name') - }) - it('should label the landmark element with the corresponding variant label text', () => { render() expect(screen.getByRole('region')).toEqual(screen.getByLabelText('Information')) diff --git a/packages/react/src/Blankslate/Blankslate.test.tsx b/packages/react/src/Blankslate/Blankslate.test.tsx index fef3ee49460..c55859c1478 100644 --- a/packages/react/src/Blankslate/Blankslate.test.tsx +++ b/packages/react/src/Blankslate/Blankslate.test.tsx @@ -2,12 +2,11 @@ import {describe, expect, it, vi} from 'vitest' import {render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {Blankslate} from '../Blankslate' +import {implementsClassName} from '../utils/testing' +import classes from './Blankslate.module.css' describe('Blankslate', () => { - it('should support a custom `className` on the outermost, non-container element', () => { - const {container} = render(Test content) - expect(container.firstChild!.firstChild).toHaveClass('test') - }) + implementsClassName(Blankslate, classes.Blankslate) it('should render with border when border is true', () => { const {container} = render(Test content) diff --git a/packages/react/src/BranchName/__tests__/BranchName.test.tsx b/packages/react/src/BranchName/__tests__/BranchName.test.tsx index a66bcdcf4bf..3b89ce3c2d2 100644 --- a/packages/react/src/BranchName/__tests__/BranchName.test.tsx +++ b/packages/react/src/BranchName/__tests__/BranchName.test.tsx @@ -1,15 +1,13 @@ import BranchName from '../BranchName' import {render as HTMLRender} from '@testing-library/react' import {describe, expect, it} from 'vitest' +import classes from '../BranchName.module.css' +import {implementsClassName} from '../../utils/testing' describe('BranchName', () => { + implementsClassName(BranchName, classes.BranchName) it('renders an by default', () => { const {container} = HTMLRender() expect(container.firstChild?.nodeName).toEqual('A') }) - - it('should support `className` on the outermost element', () => { - const Element = () => - expect(HTMLRender().container.firstChild).toHaveClass('test-class-name') - }) }) diff --git a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx index a76bdf2f950..54105c764de 100644 --- a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx @@ -3,6 +3,8 @@ import {render as HTMLRender, screen, waitFor, within} from '@testing-library/re import {describe, expect, it, vi} from 'vitest' import userEvent from '@testing-library/user-event' import {FeatureFlags} from '../../FeatureFlags' +import {implementsClassName} from '../../utils/testing' +import classes from '../Breadcrumbs.module.css' // Helper function to render with theme and feature flags // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -25,6 +27,7 @@ globalThis.ResizeObserver = vi.fn().mockImplementation(function () { }) describe('Breadcrumbs', () => { + implementsClassName(Breadcrumbs, classes.BreadcrumbsBase) it('renders a