From 3a8ff301fca403e30435b971f7082f809b760ca1 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:21:33 +0800 Subject: [PATCH] test(web): add high-quality unit tests for Base UI wrapper primitives (#32904) --- .../base/ui/dialog/__tests__/index.spec.tsx | 70 +++++ .../ui/dropdown-menu/__tests__/index.spec.tsx | 294 ++++++++++++++++++ .../base/ui/popover/__tests__/index.spec.tsx | 107 +++++++ .../base/ui/select/__tests__/index.spec.tsx | 219 +++++++++++++ .../base/ui/tooltip/__tests__/index.spec.tsx | 95 ++++++ 5 files changed, 785 insertions(+) create mode 100644 web/app/components/base/ui/dialog/__tests__/index.spec.tsx create mode 100644 web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx create mode 100644 web/app/components/base/ui/popover/__tests__/index.spec.tsx create mode 100644 web/app/components/base/ui/select/__tests__/index.spec.tsx create mode 100644 web/app/components/base/ui/tooltip/__tests__/index.spec.tsx diff --git a/web/app/components/base/ui/dialog/__tests__/index.spec.tsx b/web/app/components/base/ui/dialog/__tests__/index.spec.tsx new file mode 100644 index 0000000000..0861fff603 --- /dev/null +++ b/web/app/components/base/ui/dialog/__tests__/index.spec.tsx @@ -0,0 +1,70 @@ +import { Dialog as BaseDialog } from '@base-ui/react/dialog' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '../index' + +describe('Dialog wrapper', () => { + describe('Rendering', () => { + it('should render dialog content when dialog is open', () => { + render( + + + Dialog Title + Dialog Description + + , + ) + + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveTextContent('Dialog Title') + expect(dialog).toHaveTextContent('Dialog Description') + }) + }) + + describe('Props', () => { + it('should not render close button when closable is omitted', () => { + render( + + + Dialog body + + , + ) + + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + }) + + it('should render close button when closable is true', () => { + render( + + + Dialog body + + , + ) + + const dialog = screen.getByRole('dialog') + const closeButton = screen.getByRole('button', { name: 'Close' }) + + expect(dialog).toContainElement(closeButton) + expect(closeButton).toHaveAttribute('aria-label', 'Close') + }) + }) + + describe('Exports', () => { + it('should map dialog aliases to the matching base dialog primitives', () => { + expect(Dialog).toBe(BaseDialog.Root) + expect(DialogTrigger).toBe(BaseDialog.Trigger) + expect(DialogTitle).toBe(BaseDialog.Title) + expect(DialogDescription).toBe(BaseDialog.Description) + expect(DialogClose).toBe(BaseDialog.Close) + }) + }) +}) diff --git a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx new file mode 100644 index 0000000000..b381078180 --- /dev/null +++ b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx @@ -0,0 +1,294 @@ +import { Menu } from '@base-ui/react/menu' +import { fireEvent, render, screen, within } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '../index' + +describe('dropdown-menu wrapper', () => { + describe('alias exports', () => { + it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => { + expect(DropdownMenu).toBe(Menu.Root) + expect(DropdownMenuPortal).toBe(Menu.Portal) + expect(DropdownMenuTrigger).toBe(Menu.Trigger) + expect(DropdownMenuSub).toBe(Menu.SubmenuRoot) + expect(DropdownMenuGroup).toBe(Menu.Group) + expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup) + }) + }) + + describe('DropdownMenuContent', () => { + it('should position content at bottom-end with default placement when props are omitted', () => { + render( + + Open + + Content action + + , + ) + + const positioner = screen.getByRole('group', { name: 'content positioner' }) + const popup = screen.getByRole('menu') + + expect(positioner).toHaveAttribute('data-side', 'bottom') + expect(positioner).toHaveAttribute('data-align', 'end') + expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument() + }) + + it('should apply custom placement when custom positioning props are provided', () => { + render( + + Open + + Custom content + + , + ) + + const positioner = screen.getByRole('group', { name: 'custom content positioner' }) + const popup = screen.getByRole('menu') + + expect(positioner).toHaveAttribute('data-side', 'top') + expect(positioner).toHaveAttribute('data-align', 'start') + expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument() + }) + + it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => { + const handlePositionerMouseEnter = vi.fn() + const handlePopupClick = vi.fn() + + render( + + Open + + Passthrough content + + , + ) + + const positioner = screen.getByRole('group', { name: 'dropdown content positioner' }) + const popup = screen.getByRole('menu') + fireEvent.mouseEnter(positioner) + fireEvent.click(popup) + + expect(positioner).toHaveAttribute('id', 'dropdown-content-positioner') + expect(popup).toHaveAttribute('id', 'dropdown-content-popup') + expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1) + expect(handlePopupClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('DropdownMenuSubContent', () => { + it('should position sub-content at left-start with default placement when props are omitted', () => { + render( + + Open + + + More actions + + Sub action + + + + , + ) + + const positioner = screen.getByRole('group', { name: 'sub positioner' }) + expect(positioner).toHaveAttribute('data-side', 'left') + expect(positioner).toHaveAttribute('data-align', 'start') + expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument() + }) + + it('should apply custom placement and forward passthrough props for sub-content when custom props are provided', () => { + const handlePositionerFocus = vi.fn() + const handlePopupClick = vi.fn() + + render( + + Open + + + More actions + + Custom sub action + + + + , + ) + + const positioner = screen.getByRole('group', { name: 'dropdown sub positioner' }) + const popup = screen.getByRole('menu', { name: 'More actions' }) + fireEvent.focus(positioner) + fireEvent.click(popup) + + expect(positioner).toHaveAttribute('data-side', 'right') + expect(positioner).toHaveAttribute('data-align', 'end') + expect(positioner).toHaveAttribute('id', 'dropdown-sub-positioner') + expect(popup).toHaveAttribute('id', 'dropdown-sub-popup') + expect(handlePositionerFocus).toHaveBeenCalledTimes(1) + expect(handlePopupClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('DropdownMenuSubTrigger', () => { + it('should render submenu trigger content when trigger children are provided', () => { + render( + + Open + + + Trigger item + + + , + ) + + expect(screen.getByRole('menuitem', { name: 'Trigger item' })).toBeInTheDocument() + }) + + it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => { + const handleClick = vi.fn() + + render( + + Open + + + + Trigger item + + + + , + ) + + const subTrigger = screen.getByRole('menuitem', { name: 'submenu action' }) + fireEvent.click(subTrigger) + + expect(subTrigger).toHaveAttribute('id', `submenu-trigger-${String(destructive)}`) + expect(subTrigger).not.toHaveAttribute('destructive') + expect(handleClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('DropdownMenuItem', () => { + it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => { + const handleClick = vi.fn() + + render( + + Open + + + Item label + + + , + ) + + const item = screen.getByRole('menuitem', { name: 'menu action' }) + fireEvent.click(item) + + expect(item).toHaveAttribute('id', `menu-item-${String(destructive)}`) + expect(item).not.toHaveAttribute('destructive') + expect(handleClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('DropdownMenuSeparator', () => { + it('should forward passthrough props and handlers when separator props are provided', () => { + const handleMouseEnter = vi.fn() + + render( + + Open + + + + , + ) + + const separator = screen.getByRole('separator', { name: 'actions divider' }) + fireEvent.mouseEnter(separator) + + expect(separator).toHaveAttribute('id', 'menu-separator') + expect(handleMouseEnter).toHaveBeenCalledTimes(1) + }) + + it('should keep surrounding menu rows rendered when separator is placed between items', () => { + render( + + Open + + First action + + Second action + + , + ) + + expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument() + expect(screen.getAllByRole('separator')).toHaveLength(1) + }) + }) +}) diff --git a/web/app/components/base/ui/popover/__tests__/index.spec.tsx b/web/app/components/base/ui/popover/__tests__/index.spec.tsx new file mode 100644 index 0000000000..9d65f8c934 --- /dev/null +++ b/web/app/components/base/ui/popover/__tests__/index.spec.tsx @@ -0,0 +1,107 @@ +import { Popover as BasePopover } from '@base-ui/react/popover' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { + Popover, + PopoverClose, + PopoverContent, + PopoverDescription, + PopoverTitle, + PopoverTrigger, +} from '..' + +describe('PopoverContent', () => { + describe('Placement', () => { + it('should use bottom placement and default offsets when placement props are not provided', () => { + render( + + Open + + Default content + + , + ) + + const positioner = screen.getByRole('group', { name: 'default positioner' }) + const popup = screen.getByRole('dialog', { name: 'default popover' }) + + expect(positioner).toHaveAttribute('data-side', 'bottom') + expect(positioner).toHaveAttribute('data-align', 'center') + expect(popup).toHaveTextContent('Default content') + }) + + it('should apply parsed custom placement and custom offsets when placement props are provided', () => { + render( + + Open + + Custom placement content + + , + ) + + const positioner = screen.getByRole('group', { name: 'custom positioner' }) + const popup = screen.getByRole('dialog', { name: 'custom popover' }) + + expect(positioner).toHaveAttribute('data-side', 'top') + expect(positioner).toHaveAttribute('data-align', 'end') + expect(popup).toHaveTextContent('Custom placement content') + }) + }) + + describe('Passthrough props', () => { + it('should forward positionerProps and popupProps when passthrough props are provided', () => { + const onPopupClick = vi.fn() + + render( + + Open + + Popover body + + , + ) + + const positioner = screen.getByRole('group', { name: 'popover positioner' }) + const popup = screen.getByRole('dialog', { name: 'popover content' }) + fireEvent.click(popup) + + expect(positioner).toHaveAttribute('id', 'popover-positioner-id') + expect(popup).toHaveAttribute('id', 'popover-popup-id') + expect(onPopupClick).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe('Popover aliases', () => { + describe('Export mapping', () => { + it('should map aliases to the matching base popover primitives when wrapper exports are imported', () => { + expect(Popover).toBe(BasePopover.Root) + expect(PopoverTrigger).toBe(BasePopover.Trigger) + expect(PopoverClose).toBe(BasePopover.Close) + expect(PopoverTitle).toBe(BasePopover.Title) + expect(PopoverDescription).toBe(BasePopover.Description) + }) + }) +}) diff --git a/web/app/components/base/ui/select/__tests__/index.spec.tsx b/web/app/components/base/ui/select/__tests__/index.spec.tsx new file mode 100644 index 0000000000..7744a0e22c --- /dev/null +++ b/web/app/components/base/ui/select/__tests__/index.spec.tsx @@ -0,0 +1,219 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../index' + +const renderOpenSelect = ({ + triggerProps = {}, + contentProps = {}, + onValueChange, +}: { + triggerProps?: Record + contentProps?: Record + onValueChange?: (value: string | null) => void +} = {}) => { + return render( + , + ) +} + +describe('Select wrappers', () => { + describe('SelectTrigger', () => { + it('should render clear button when clearable is true and loading is false', () => { + renderOpenSelect({ + triggerProps: { clearable: true }, + }) + + expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument() + }) + + it('should hide clear button when loading is true', () => { + renderOpenSelect({ + triggerProps: { clearable: true, loading: true }, + }) + + expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument() + }) + + it('should forward native trigger props when trigger props are provided', () => { + renderOpenSelect({ + triggerProps: { + 'aria-label': 'Choose option', + 'disabled': true, + }, + }) + + const trigger = screen.getByRole('combobox', { name: 'Choose option' }) + expect(trigger).toBeDisabled() + }) + + it('should call onClear and stop click propagation when clear button is clicked', () => { + const onClear = vi.fn() + const onTriggerClick = vi.fn() + + renderOpenSelect({ + triggerProps: { + clearable: true, + onClear, + onClick: onTriggerClick, + }, + }) + + fireEvent.click(screen.getByRole('button', { name: /clear selection/i })) + + expect(onClear).toHaveBeenCalledTimes(1) + expect(onTriggerClick).not.toHaveBeenCalled() + }) + + it('should stop mouse down propagation when clear button receives mouse down', () => { + const onTriggerMouseDown = vi.fn() + + renderOpenSelect({ + triggerProps: { + clearable: true, + onMouseDown: onTriggerMouseDown, + }, + }) + + fireEvent.mouseDown(screen.getByRole('button', { name: /clear selection/i })) + + expect(onTriggerMouseDown).not.toHaveBeenCalled() + }) + + it('should not throw when clear button is clicked without onClear handler', () => { + renderOpenSelect({ + triggerProps: { clearable: true }, + }) + + const clearButton = screen.getByRole('button', { name: /clear selection/i }) + expect(() => fireEvent.click(clearButton)).not.toThrow() + }) + }) + + describe('SelectContent', () => { + it('should use default placement when placement is not provided', () => { + renderOpenSelect() + + const positioner = screen.getByRole('group', { name: 'select positioner' }) + expect(positioner).toHaveAttribute('data-side', 'bottom') + expect(positioner).toHaveAttribute('data-align', 'start') + }) + + it('should apply custom placement when placement props are provided', () => { + renderOpenSelect({ + contentProps: { + placement: 'top-end', + sideOffset: 12, + alignOffset: 6, + }, + }) + + const positioner = screen.getByRole('group', { name: 'select positioner' }) + expect(positioner).toHaveAttribute('data-side', 'top') + expect(positioner).toHaveAttribute('data-align', 'end') + }) + + it('should forward passthrough props to positioner popup and list when passthrough props are provided', () => { + const onPositionerMouseEnter = vi.fn() + const onPopupClick = vi.fn() + const onListFocus = vi.fn() + + render( + , + ) + + const positioner = screen.getByRole('group', { name: 'select positioner' }) + const popup = screen.getByRole('dialog', { name: 'select popup' }) + const list = screen.getByRole('listbox', { name: 'select list' }) + + fireEvent.mouseEnter(positioner) + fireEvent.click(popup) + fireEvent.focus(list) + + expect(positioner).toHaveAttribute('id', 'select-positioner') + expect(popup).toHaveAttribute('id', 'select-popup') + expect(list).toHaveAttribute('id', 'select-list') + expect(onPositionerMouseEnter).toHaveBeenCalledTimes(1) + expect(onPopupClick).toHaveBeenCalledTimes(1) + expect(onListFocus).toHaveBeenCalledTimes(1) + }) + }) + + describe('SelectItem', () => { + it('should render options when children are provided', () => { + renderOpenSelect() + + expect(screen.getByRole('option', { name: 'Seattle' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'New York' })).toBeInTheDocument() + }) + + it('should not call onValueChange when disabled item is clicked', () => { + const onValueChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('option', { name: 'Disabled New York' })) + + expect(onValueChange).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx b/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx new file mode 100644 index 0000000000..4582f07cbe --- /dev/null +++ b/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx @@ -0,0 +1,95 @@ +import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../index' + +describe('TooltipContent', () => { + describe('Placement and offsets', () => { + it('should use default top placement when placement is not provided', () => { + render( + + Trigger + + Tooltip body + + , + ) + + const popup = screen.getByRole('tooltip', { name: 'default tooltip' }) + expect(popup).toHaveAttribute('data-side', 'top') + expect(popup).toHaveAttribute('data-align', 'center') + expect(popup).toHaveTextContent('Tooltip body') + }) + + it('should apply custom placement when placement props are provided', () => { + render( + + Trigger + + Custom tooltip body + + , + ) + + const popup = screen.getByRole('tooltip', { name: 'custom tooltip' }) + expect(popup).toHaveAttribute('data-side', 'bottom') + expect(popup).toHaveAttribute('data-align', 'start') + expect(popup).toHaveTextContent('Custom tooltip body') + }) + }) + + describe('Variant and popup props', () => { + it('should render popup content when variant is plain', () => { + render( + + Trigger + + Plain tooltip body + + , + ) + + expect(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body') + }) + + it('should forward popup props and handlers when popup props are provided', () => { + const onMouseEnter = vi.fn() + + render( + + Trigger + + Tooltip body + + , + ) + + const popup = screen.getByRole('tooltip', { name: 'help text' }) + fireEvent.mouseEnter(popup) + + expect(popup).toHaveAttribute('id', 'tooltip-popup-id') + expect(popup).toHaveAttribute('data-track-id', 'tooltip-track') + expect(onMouseEnter).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe('Tooltip aliases', () => { + it('should map alias exports to BaseTooltip components when wrapper exports are imported', () => { + expect(TooltipProvider).toBe(BaseTooltip.Provider) + expect(Tooltip).toBe(BaseTooltip.Root) + expect(TooltipTrigger).toBe(BaseTooltip.Trigger) + }) +})