// @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('Button', () => { it('renders children, icon, and forwards clicks', () => { const onClick = vi.fn() render(} onClick={onClick}>Go) const button = screen.getByRole('button', { name: 'Go' }) expect(screen.getByTestId('ic')).toBeDefined() fireEvent.click(button) expect(onClick).toHaveBeenCalledTimes(1) }) it('disabled blocks interaction', () => { const onClick = vi.fn() render(No) fireEvent.click(screen.getByRole('button')) expect(onClick).not.toHaveBeenCalled() }) it('outline variant renders a bordered cancel-style button', () => { render(Cancel) expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined() }) }) describe('Pill', () => { it('is a span when static, a button when clickable', () => { const { rerender } = render(tab) expect(screen.queryByRole('button')).toBeNull() rerender( {}}>tab) expect(screen.getByRole('button', { name: 'tab' })).toBeDefined() }) it('active and className land on both static and interactive forms', () => { const { container, rerender } = render(tab) const asSpan = container.firstElementChild as HTMLElement expect(asSpan.classList.contains('x')).toBe(true) rerender( {}}>tab) const asButton = screen.getByRole('button') expect(asButton.classList.contains('x')).toBe(true) }) }) describe('Input', () => { it('forwards value/onChange and renders the leading icon', () => { const onChange = vi.fn() render(} value="q" onChange={onChange} placeholder="search" />) const input = screen.getByPlaceholderText('search') expect(input.value).toBe('q') fireEvent.change(input, { target: { value: 'qq' } }) expect(onChange).toHaveBeenCalled() expect(screen.getByTestId('ic')).toBeDefined() }) }) describe('Menu', () => { const items = [ { id: 'a', label: 'Alpha' }, { id: 'b', label: 'Beta', disabled: true }, ] it('shows items only while open; select fires onSelect', () => { const onSelect = vi.fn() const { rerender } = render( trigger} items={items} onSelect={onSelect} onClose={() => {}} />) expect(screen.queryByRole('menu')).toBeNull() rerender( trigger} items={items} selectedId="a" onSelect={onSelect} onClose={() => {}} />) fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) expect(onSelect).toHaveBeenCalledWith('a') }) it('disabled item does not select; Escape and outside pointerdown close', () => { const onSelect = vi.fn() const onClose = vi.fn() render( trigger} items={items} onSelect={onSelect} onClose={onClose} />) fireEvent.click(screen.getByRole('menuitem', { name: 'Beta' })) expect(onSelect).not.toHaveBeenCalled() fireEvent.keyDown(document, { key: 'Escape' }) expect(onClose).toHaveBeenCalledTimes(1) fireEvent.pointerDown(document.body) expect(onClose).toHaveBeenCalledTimes(2) }) it('inside pointerdown does not close', () => { const onClose = vi.fn() render( trigger} items={items} onSelect={() => {}} onClose={onClose} />) fireEvent.pointerDown(screen.getByRole('menuitem', { name: 'Alpha' })) expect(onClose).not.toHaveBeenCalled() }) it('selected item shows the trailing check; align=end, side=top, and className apply', () => { const { container } = render( trigger} items={items} selectedId="a" onSelect={() => {}} onClose={() => {}} />) expect((container.firstElementChild as HTMLElement).classList.contains('x')).toBe(true) const menu = screen.getByRole('menu') expect(menu.className).toMatch(/sideTop|alignEnd/) const selected = screen.getByRole('menuitem', { name: 'Alpha' }) expect(selected.querySelector('svg')).not.toBeNull() const other = screen.getByRole('menuitem', { name: 'Beta' }) expect(other.querySelector('svg')).toBeNull() fireEvent.keyDown(document, { key: 'a' }) }) it('renders a leading icon and a separator between groups', () => { render( trigger} items={[ { id: 'a', label: 'Alpha', icon: }, { type: 'separator', id: 's1' }, { id: 'c', label: 'Create' }, ]} onSelect={() => {}} onClose={() => {}} />) expect(screen.getByTestId('ic')).toBeDefined() expect(screen.getByRole('separator')).toBeDefined() }) it('renders a non-interactive heading label and a danger row', () => { const onSelect = vi.fn() render( trigger} items={[ { type: 'label', id: 'h', text: 'Group by' }, { id: 'del', label: 'Delete', danger: true }, ]} onSelect={onSelect} onClose={() => {}} />) const heading = screen.getByText('Group by') expect(heading.getAttribute('role')).toBe('presentation') // The heading is not a menu item β only the danger row is interactive. expect(screen.getAllByRole('menuitem')).toHaveLength(1) const danger = screen.getByRole('menuitem', { name: 'Delete' }) expect(danger.className).toMatch(/danger/) fireEvent.click(danger) expect(onSelect).toHaveBeenCalledWith('del') }) it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => { const onClose = vi.fn() const { rerender } = render( trigger} items={items} onSelect={() => {}} onClose={onClose} />) fireEvent.pointerLeave(screen.getByRole('menu')) expect(onClose).toHaveBeenCalledTimes(1) rerender( trigger} items={items} onSelect={() => {}} onClose={onClose} />) fireEvent.pointerLeave(screen.getByRole('menu')) expect(onClose).toHaveBeenCalledTimes(1) }) it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => { const rowClick = vi.fn() render( trigger} items={items} onSelect={() => {}} onClose={() => {}} /> ) fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) expect(rowClick).not.toHaveBeenCalled() }) it('opens a submenu on hover and selects a nested item', () => { const onSelect = vi.fn() render( trigger} items={[ { id: 'plain', label: 'Plain' }, { id: 'new', label: 'New Workspace', submenu: [ { id: 'ok', label: 'Create ok', icon: }, ], }, ]} onSelect={onSelect} onClose={() => {}} />) const plain = screen.getByRole('menuitem', { name: 'Plain' }) fireEvent.mouseEnter(plain.parentElement as HTMLElement) fireEvent.focus(plain) const parent = screen.getByRole('menuitem', { name: 'New Workspace' }) const wrap = parent.parentElement as HTMLElement fireEvent.click(parent) expect(onSelect).not.toHaveBeenCalled() fireEvent.focus(parent) fireEvent.mouseEnter(wrap) expect(screen.getByTestId('sub-ic')).toBeDefined() fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' })) expect(onSelect).toHaveBeenCalledWith('ok') fireEvent.mouseLeave(wrap) expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull() }) it('portal mode prefers getAnchorRect over measuring its own wrapper', () => { const rect = { left: 40, right: 72, top: 100, bottom: 128, width: 32, height: 28, x: 40, y: 100, toJSON: () => ({}) } as DOMRect render( rect} anchor={null} items={items} onSelect={() => {}} onClose={() => {}} />) const menu = screen.getByRole('menu') // side=bottom, align=start: below the host-supplied rect, left-aligned. expect(menu.style.left).toBe('40px') expect(menu.style.top).toBe('132px') }) it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => { render( null} anchor={null} items={items} onSelect={() => {}} onClose={() => {}} />) expect(screen.queryByRole('menu')).toBeNull() }) it('portal mode renders the list under body, positions it fixed, and still closes on outside pointerdown', () => { const onSelect = vi.fn() const onClose = vi.fn() const { container } = render( trigger} items={items} onSelect={onSelect} onClose={onClose} />) const menu = screen.getByRole('menu') // Outside the anchor wrapper subtree β overflow-clipping ancestors can't crop it. expect(container.contains(menu)).toBe(false) expect(menu.parentElement).toBe(document.body) expect(menu.style.top).not.toBe('') fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) expect(onSelect).toHaveBeenCalledWith('a') fireEvent.pointerDown(menu) expect(onClose).not.toHaveBeenCalled() // Non-Node targets (e.g. window itself) are ignored, not treated as outside. const nonNodeTarget = new Event('pointerdown', { bubbles: true }) Object.defineProperty(nonNodeTarget, 'target', { value: window }) document.dispatchEvent(nonNodeTarget) expect(onClose).not.toHaveBeenCalled() fireEvent.pointerDown(document.body) expect(onClose).toHaveBeenCalledTimes(1) }) it('portal mode positions from the opposite edges for align=end / side=top', () => { render( trigger} items={items} onSelect={() => {}} onClose={() => {}} />) const menu = screen.getByRole('menu') expect(menu.style.right).not.toBe('') expect(menu.style.bottom).not.toBe('') expect(menu.style.left).toBe('') expect(menu.style.top).toBe('') }) }) describe('Modal', () => { it('is absent while closed; Escape and mask click call onClose', () => { const onClose = vi.fn() const { rerender } = render( body) expect(screen.queryByRole('dialog')).toBeNull() rerender( Create}> ) expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined() expect(screen.getByText('Name it.')).toBeDefined() fireEvent.keyDown(document, { key: 'a' }) expect(onClose).not.toHaveBeenCalled() fireEvent.keyDown(document, { key: 'Escape' }) expect(onClose).toHaveBeenCalledTimes(1) // Mask is the presentation sibling behind the dialog. const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement fireEvent.click(mask) expect(onClose).toHaveBeenCalledTimes(2) }) }) describe('ConnectionBanner', () => { it('renders only while reconnecting', () => { const { container, rerender } = render() expect(container.firstChild).toBeNull() rerender() expect(container.textContent).toContain('ιθΏ') }) })