Merge remote-tracking branch 'origin/master' into worktree/web-remote-markdown-images

# Conflicts:
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-primitives/README.md
#	packages/client/ui-primitives/README.zh.md
#	packages/client/ui-primitives/src/markdown/MarkdownText.tsx
#	packages/client/ui-primitives/tests/markdown.spec.tsx
This commit is contained in:
Yichen Jiang
2026-08-03 16:09:51 +08:00
2018 changed files with 163222 additions and 14125 deletions

View File

@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, 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'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
afterEach(cleanup)
@@ -160,16 +161,77 @@ describe('Menu', () => {
expect(onSelect).toHaveBeenCalledWith('del')
})
it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => {
const onClose = vi.fn()
const { rerender } = render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByRole('menu'))
expect(onClose).toHaveBeenCalledTimes(1)
rerender(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByRole('menu'))
expect(onClose).toHaveBeenCalledTimes(1)
it('closeOnPointerLeave closes a grace after the pointer leaves trigger and list; default never does', () => {
vi.useFakeTimers()
try {
const onClose = vi.fn()
const { rerender } = render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
fireEvent.pointerLeave(wrapper)
// Still open through the grace: the pointer may be crossing the gap.
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
expect(onClose).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(1) })
expect(onClose).toHaveBeenCalledTimes(1)
rerender(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(onClose).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('coming back inside the grace keeps the list open (trigger and list are one region)', () => {
vi.useFakeTimers()
try {
const onClose = vi.fn()
render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(onClose).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('a close from selection disarms the pending grace close', () => {
vi.useFakeTimers()
try {
const onClose = vi.fn()
const { rerender } = render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
fireEvent.pointerLeave(wrapper)
// The owner closes for its own reason (selection/Escape) mid-grace; the
// armed timer must not survive to shut a list reopened right after.
rerender(
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(onClose).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('leaving a closed list arms nothing', () => {
vi.useFakeTimers()
try {
const onClose = vi.fn()
render(
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByText('trigger').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(onClose).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => {
@@ -324,11 +386,17 @@ describe('Modal', () => {
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
expect(screen.queryByRole('dialog')).toBeNull()
rerender(
<Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." contentClassName="scrolling-content" footer={<button type="button">Create</button>}>
<input aria-label="name" />
</Modal>)
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
const dialog = screen.getByRole('dialog', { name: 'Create new workspace' })
expect(dialog).toBeDefined()
// The full-page layer escapes caller stacking contexts but remains in
// this document/current WebUI window.
expect(dialog.parentElement?.parentElement).toBe(document.body)
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
expect(screen.getByText('Name it.')).toBeDefined()
expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content')
fireEvent.keyDown(document, { key: 'a' })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(document, { key: 'Escape' })

View File

@@ -31,6 +31,24 @@ describe('highlightToHtml', () => {
expect(highlightToHtml('x', 'cobol')).toBeUndefined()
expect(highlightToHtml('x', undefined)).toBeUndefined()
})
// Every read-tool language hint whose grammar loads lazily (the boot set —
// ts/js/bash/sh/json — is covered above). Touching each one drives its own
// dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
const LAZY_ALIASES = [
'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
'xml', 'lua',
]
it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
// First touch returns the plain fallback (undefined) and starts the import.
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toBeUndefined()
// Once every grammar has registered, the same call highlights.
await vi.waitFor(() => {
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki')
})
})
})
describe('CodeBlock', () => {

View File

@@ -0,0 +1,182 @@
// @vitest-environment jsdom
// DiffBlock: the per-file hunk rows (path header, removed block, added block),
// the same-file second-hunk gap separator, the `+A -R · N file(s)` footer and
// its singular/plural, the head/tail height cap and its expand control, the
// empty-diffs null render, and the copy control writing the prefixed diff text
// on both the accepted and the refused clipboard paths. writeClipboard's own
// return contract is pinned in terminal-block.spec.tsx (the shared seam), so
// only its DOM consequence is asserted here.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
/** The rendered body rows, one string per visible line (CSS-module class prefix). */
function bodyRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '')
}
/** Only the changed rows (add/del), excluding the path header and gap chrome. */
function changeRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '')
}
/** `count` numbered added lines as one hunk's newText. */
function added(count: number): string {
return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n')
}
describe('DiffBlock structure', () => {
it('renders a create as a path header and an added block (no removed side)', () => {
const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('notes/new.txt')).toBeTruthy()
// No removed rows: both change lines are added.
expect(changeRows(container)).toEqual(['hello', 'world'])
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2)
})
it('renders an edit as a removed block above an added block', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1)
expect(changeRows(container)).toEqual(['old', 'new'])
})
it('opens a same-file second hunk with a gap instead of repeating the path', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'x', newText: 'y' },
{ path: 'a.ts', oldText: 'p', newText: 'q' },
]
const { container } = render(<DiffBlock diffs={diffs} />)
// One path header, one gap row.
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1)
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1)
})
it('opens a new file with its own path header', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'x', newText: 'y' },
{ path: 'b.ts', oldText: 'p', newText: 'q' },
]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2)
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0)
})
it('renders nothing for empty diffs', () => {
const { container } = render(<DiffBlock diffs={[]} />)
expect(container.firstChild).toBeNull()
})
it('treats a trailing newline as a terminator, not an extra blank line', () => {
// A create whose newText ends in a newline is one added line, not two, and
// the footer counts one — the phantom `+ ` empty line the naive split drew.
const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
expect(changeRows(container)).toEqual(['hello'])
expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('renders a full deletion as removed-only with no phantom added line', () => {
// newText '' is zero added lines: an empty string must contribute nothing.
const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
})
it('keeps a genuine interior blank line', () => {
const { container } = render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x\n\ny' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3)
})
})
describe('DiffBlock footer', () => {
it('counts added and removed lines and one file', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
})
it('pluralizes the distinct-file count', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: null, newText: 'x' },
{ path: 'b.ts', oldText: null, newText: 'y' },
]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
})
})
describe('DiffBlock height cap', () => {
it('shows head and tail with an expand control past the cap, then all lines expanded', () => {
// One added line over the default cap forces the collapse.
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }]
// The path header counts as a row, so a body of maxLines added lines plus
// the header is one over the cap.
const { container } = render(<DiffBlock diffs={diffs} />)
const toggle = screen.getByRole('button', { name: /展开其余/ })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
// Collapsed shows fewer rows than the full body.
const collapsedCount = bodyRows(container).length
expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1)
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true')
expect(bodyRows(container).length).toBeGreaterThan(collapsedCount)
})
it('shows no expand control at or under the cap', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }]
render(<DiffBlock diffs={diffs} maxLines={16} />)
expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull()
})
})
describe('DiffBlock copy', () => {
it('copies the prefixed diff text and flips the label on success', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'old', newText: 'new' },
{ path: 'a.ts', oldText: 'p', newText: 'q' },
]
render(<DiffBlock diffs={diffs} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
// Path header, del/add prefixes, and the same-file gap all reach the clipboard.
expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q')
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('keeps the label on a refused clipboard write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('ignores a second click while the copied label is showing', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) })
expect(writeText).toHaveBeenCalledTimes(1)
})
})

View File

@@ -2,6 +2,7 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
afterEach(cleanup)
beforeEach(() => { vi.useFakeTimers() })
@@ -16,7 +17,13 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
})
}
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
function mount(props: {
openDelayMs?: number
disabled?: boolean
copyText?: string
copyLabel?: string
copiedLabel?: string
} = {}) {
const view = render(
<HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
)
@@ -25,6 +32,19 @@ function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
return { view, anchor, wrapper: anchor.parentElement as HTMLElement }
}
/** Install the async browser clipboard and restore its prior host shape. */
function installClipboard(writeText: (text: string) => Promise<void>): () => void {
const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return () => {
if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', prior)
}
}
describe('HoverCard', () => {
it('opens after the dwell delay, positioned right of the anchor', () => {
const { wrapper } = mount()
@@ -54,18 +74,47 @@ describe('HoverCard', () => {
expect(screen.queryByText('card body')).toBeNull()
})
it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
it('pointerleave closes an open card a grace later; re-enter after that restarts the dwell', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
expect(screen.getByText('card body')).toBeTruthy()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.queryByText('card body')).toBeNull()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('reaching the card inside the grace keeps it open without restarting the dwell', () => {
// The portaled card is a React child of the wrapper, so the pointer
// arriving on it re-enters the wrapper — the gesture the 8px anchor gap
// used to make impossible.
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('re-entering while open does not queue a second dwell', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.pointerEnter(wrapper)
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
// A dwell restarted by the redundant enter would reopen the card here.
act(() => { vi.advanceTimersByTime(500) })
expect(screen.queryByText('card body')).toBeNull()
})
it('a press inside the anchor dismisses the card without waiting for disabled', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
@@ -78,6 +127,237 @@ describe('HoverCard', () => {
expect(screen.queryByText('card body')).toBeNull()
})
it('a press on the card starts a selection instead of dismissing it', () => {
// The card is a React child of the wrapper, so capture-phase presses on
// it reach the wrapper's dismissal handler too; they must not close it,
// or the first pointerdown of a text-selection drag would kill the card.
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.pointerDown(screen.getByText('card body'))
// Still mounted after a grace's worth of time: no close was armed either.
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('keeps a completed card selection instead of treating its click as copy', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
const selection = window.getSelection()
if (selection === null) throw new Error('jsdom selection API unavailable')
try {
const { wrapper } = mount({ copyText: 'card body', copyLabel: 'Copy' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button', { name: 'Copy: card body' })
const selectedText = screen.getByText('card body')
const cardRange = document.createRange()
cardRange.selectNodeContents(selectedText)
selection.addRange(cardRange)
await act(async () => { fireEvent.click(card) })
expect(writeText).not.toHaveBeenCalled()
expect(selection.toString()).toBe('card body')
expect(screen.getByText('card body')).toBeTruthy()
// Firefox supports multiple selection ranges: any range intersecting
// this card wins, not only the first.
selection.removeAllRanges()
const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({
isCollapsed: false,
rangeCount: 2,
getRangeAt: vi.fn((index: number) => ({
intersectsNode: () => index === 1,
})),
} as unknown as Selection)
await act(async () => { fireEvent.click(card) })
expect(writeText).not.toHaveBeenCalled()
getSelection.mockRestore()
// A non-collapsed selection elsewhere does not block this card.
const anchorRange = document.createRange()
anchorRange.selectNodeContents(screen.getByText('row'))
selection.addRange(anchorRange)
await act(async () => { fireEvent.click(card) })
expect(writeText).toHaveBeenCalledWith('card body')
} finally {
selection.removeAllRanges()
restoreClipboard()
}
})
it('a press while closed leaves the card closed', () => {
mount()
fireEvent.pointerDown(screen.getByText('row'))
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
it('copies its configured value and shows success only for the feedback window', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({
copyText: '/full/path',
copyLabel: 'Copy path',
copiedLabel: 'Copied',
})
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button', { name: 'Copy path: /full/path' })
const status = screen.getByRole('status')
expect(status.textContent).toBe('')
expect(card.contains(status)).toBe(false)
Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 })
await act(async () => { fireEvent.click(card) })
expect(writeText).toHaveBeenCalledWith('/full/path')
expect(status.textContent).toBe('Copied')
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
expect(card.style.minHeight).toBe('96px')
// Repeated activation while feedback is visible neither rewrites nor
// extends the one-second success window.
await act(async () => { fireEvent.click(card) })
expect(writeText).toHaveBeenCalledOnce()
act(() => { vi.advanceTimersByTime(999) })
expect(status.textContent).toBe('Copied')
act(() => { vi.advanceTimersByTime(1) })
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
expect(card.style.minHeight).toBe('')
expect(status.textContent).toBe('')
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('supports button keys and ignores unrelated keys', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button')
fireEvent.keyDown(card, { key: 'Escape' })
expect(writeText).not.toHaveBeenCalled()
await act(async () => { fireEvent.keyDown(card, { key: 'Enter' }) })
expect(writeText).toHaveBeenCalledOnce()
act(() => { vi.advanceTimersByTime(1000) })
await act(async () => { fireEvent.keyDown(card, { key: ' ' }) })
expect(writeText).toHaveBeenCalledTimes(2)
} finally {
restoreClipboard()
}
})
it('keeps its content when the clipboard rejects the write', async () => {
const writeText = vi.fn(async () => { throw new Error('denied') })
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { fireEvent.click(screen.getByRole('button')) })
expect(screen.queryByText('Copied')).toBeNull()
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('unmount clears copied feedback', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const { view, wrapper } = mount({ copyText: 'value' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { fireEvent.click(screen.getByRole('button')) })
expect(vi.getTimerCount()).toBe(1)
view.unmount()
expect(vi.getTimerCount()).toBe(0)
} finally {
restoreClipboard()
}
})
it('clears copied feedback when the card closes', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { fireEvent.click(screen.getByRole('button')) })
expect(screen.getByRole('status').textContent).toBe('Copied')
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.queryByText('Copied')).toBeNull()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('does not create copied feedback after an in-flight write unmounts', async () => {
let acceptWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
const restoreClipboard = installClipboard(writeText)
try {
const { view, wrapper } = mount({ copyText: 'value' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.click(screen.getByRole('button'))
expect(writeText).toHaveBeenCalledOnce()
view.unmount()
await act(async () => { acceptWrite?.() })
expect(vi.getTimerCount()).toBe(0)
} finally {
restoreClipboard()
}
})
it('does not restore copied feedback after an in-flight card closes', async () => {
let acceptWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.click(screen.getByRole('button'))
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { acceptWrite?.() })
expect(vi.getTimerCount()).toBe(0)
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('coalesces activations while the clipboard write is in flight', async () => {
let acceptWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button')
fireEvent.click(card)
fireEvent.click(card)
expect(writeText).toHaveBeenCalledOnce()
await act(async () => { acceptWrite?.() })
expect(screen.getByRole('status').textContent).toBe('Copied')
} finally {
restoreClipboard()
}
})
it('disabled suppresses opening entirely', () => {
const { wrapper } = mount({ disabled: true })
fireEvent.pointerEnter(wrapper)
@@ -135,6 +415,7 @@ describe('HoverCard', () => {
expect(card.style.left).toBe('308px')
expect(card.style.top).toBe('90px')
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.queryByText('card body')).toBeNull()
})

View File

@@ -2,7 +2,7 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import { IconApiOutline14, IconFolderClose16, IconSendOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconSendOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (45 deepsuite + 14 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(60)
it('exports the full P-I set (45 deepsuite + 15 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(61)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
@@ -36,11 +36,13 @@ describe('ic_ds_ icon set', () => {
expect(svg.classList.contains('x')).toBe(true)
})
it('native defaults: 14-glyphs default 14, 16-glyphs default 16', () => {
it('each glyph defaults to its own drawn size, not one set-wide default', () => {
const api = render(<IconApiOutline14 />)
expect(api.container.querySelector('svg')!.getAttribute('width')).toBe('14')
const folder = render(<IconFolderClose16 />)
expect(folder.container.querySelector('svg')!.getAttribute('width')).toBe('16')
const archive = render(<IconArchiveOutline20 />)
expect(archive.container.querySelector('svg')!.getAttribute('width')).toBe('20')
})
})

View File

@@ -94,6 +94,11 @@ describe('MarkdownText', () => {
expect(done.container.querySelector('pre.shiki')).not.toBeNull()
})
it('forwards localized labels to fenced code blocks', () => {
render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
})
it('renders absolute HTTP(S) images with bounded presentation', () => {
const markdown = [
'![secure diagram](https://example.com/secure.png)',
@@ -147,6 +152,39 @@ describe('MarkdownText', () => {
expect(container.querySelectorAll('li')).toHaveLength(2)
expect(screen.getByText('**unfinished')).toBeTruthy()
})
it('renders inline and display TeX through KaTeX without enabling trusted commands', () => {
const source = [
'Einstein wrote $E = mc^2$.',
'',
'$$',
'\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
'$$',
'',
'$\\href{javascript:alert(1)}{unsafe}$',
].join('\n')
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex')).toHaveLength(3)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}')
expect(container.querySelector('a')).toBeNull()
})
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'
const live = render(<MarkdownText text={partial} streaming />)
expect(live.container.querySelector('.katex')).toBeNull()
expect(live.container.querySelector('.katex-error')).toBeNull()
expect(live.container.textContent).toContain('\\frac{\\partial \\mathbf{u}}{\\partial')
live.rerender(<MarkdownText text={complete} />)
expect(live.container.querySelectorAll('.katex')).toHaveLength(1)
expect(live.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(live.container.querySelector('.katex-error')).toBeNull()
})
})
describe('JsonBlock', () => {

View File

@@ -0,0 +1,241 @@
// @vitest-environment jsdom
// ReadBlock + the highlightLines token path: the banner (label, language, the
// "showing N of M" note only when the read is a window, copy control), the
// gutter-numbered rows keeping the file's own line numbers, the shiki per-line
// highlighting resolved to css-variables token spans with an identical-geometry
// plain fallback for an unknown/absent language, the head/tail height cap and
// its expand control, and the copy control writing the raw window text on both
// the accepted and refused clipboard paths.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
/** `count` lines starting at `first`, each with distinct text. */
function lines(count: number, first = 1): ReadBlockLine[] {
return Array.from({ length: count }, (_value, index) => ({ number: first + index, text: `line ${first + index}` }))
}
/** The rendered rows as `<gutter><content>` strings (CSS-module class prefix). */
function rowTexts(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
}
/** The gutter numbers of the rendered rows, in order. */
function gutters(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_gutter_"]')].map(cell => cell.textContent ?? '')
}
describe('highlightLines', () => {
it('tokenizes a registered grammar into per-line css-variables runs', () => {
const result = highlightLines('const x = 1\n// c', 'ts')
expect(result).not.toBeUndefined()
expect(result).toHaveLength(2)
// The keyword run carries a color style through a --shiki-* custom property.
const keyword = result![0]!.find(span => span.text === 'const')
expect(keyword?.style?.color).toContain('var(--shiki-')
// Whitespace between tokens is a run of its own; the comment is line two.
expect(result![0]!.map(span => span.text).join('')).toBe('const x = 1')
expect(result![1]!.map(span => span.text).join('')).toBe('// c')
})
it('colors every run through a --shiki-* custom property', () => {
// The css-variables theme colors even the whitespace run (as the foreground
// token), so every run is a styled span; the plain fallback is the whole
// unknown-language path, not a per-run one.
const result = highlightLines('const x = 1', 'ts')
for (const span of result!) for (const run of span) expect(run.style.color).toContain('var(--shiki-')
})
it('drops the trailing terminator line so the run count matches the source lines', () => {
// `a\n` tokenizes to two lines in shiki (the second empty); the caller's own
// line array has one entry, so the terminator line is dropped.
const result = highlightLines('const a = 1\n', 'ts')
expect(result).toHaveLength(1)
})
it('keeps a genuinely blank final line when the source ends in two newlines', () => {
const result = highlightLines('a\n\n', 'ts')
expect(result).toHaveLength(2)
expect(result![1]).toEqual([])
})
it('returns undefined for an unknown or absent language', () => {
expect(highlightLines('x', 'cobol')).toBeUndefined()
expect(highlightLines('x', undefined)).toBeUndefined()
})
it('loads a lazy grammar on first use: plain first, highlighted after it registers', async () => {
// A boot grammar (ts) is ready synchronously; a lazy grammar (python) is
// not, so the first call renders plain and imports the grammar, and a
// subscriber fires once it registers, after which the same call highlights.
let notified = 0
const stop = subscribeGrammarLoaded(() => { notified += 1 })
// First touch: grammar not loaded yet, so plain fallback while it imports.
expect(highlightLines('def f(): pass', 'py')).toBeUndefined()
// The import + loadLanguageSync resolve on a microtask; wait for the notify.
await vi.waitFor(() => { expect(notified).toBeGreaterThan(0) })
expect(grammarLoadCount()).toBeGreaterThan(0)
const result = highlightLines('def f(): pass', 'py')
expect(result).not.toBeUndefined()
// `def` is a python keyword and carries a --shiki-* color once highlighted.
const keyword = result!.flat().find(span => span.text === 'def')
expect(keyword?.style?.color).toContain('var(--shiki-')
stop()
})
})
describe('ReadBlock rows', () => {
it('renders one gutter-numbered row per line, keeping the file line numbers', () => {
const view = render(<ReadBlock label="a.ts" lines={lines(3, 41)} totalLines={3} />)
expect(gutters(view.container)).toEqual(['41', '42', '43'])
expect(rowTexts(view.container)).toEqual(['41line 41', '42line 42', '43line 43'])
})
it('highlights the content for a known language into token spans', () => {
const view = render(
<ReadBlock label="a.ts" lang="ts" lines={[{ number: 1, text: 'const a = 1' }]} totalLines={1} />,
)
const content = view.container.querySelector('[class^="_content_"]')
expect(content?.querySelectorAll('span[style]').length).toBeGreaterThan(1)
expect(content?.textContent).toBe('const a = 1')
})
it('renders the content as bare text with no span wrappers for an unknown language', () => {
const view = render(
<ReadBlock label="a.cob" lang="cobol" lines={[{ number: 1, text: 'IDENT DIVISION.' }]} totalLines={1} />,
)
const content = view.container.querySelector('[class^="_content_"]')
expect(content?.querySelectorAll('span').length).toBe(0)
expect(content?.textContent).toBe('IDENT DIVISION.')
})
it('renders bare text when no language is given', () => {
const view = render(<ReadBlock label="x" lines={[{ number: 1, text: 'plain' }]} totalLines={1} />)
const content = view.container.querySelector('[class^="_content_"]')
expect(content?.querySelectorAll('span').length).toBe(0)
expect(view.getByText('plain')).toBeTruthy()
})
})
describe('ReadBlock banner', () => {
it('shows the label, the language, and the count note when the read is a window', () => {
const view = render(<ReadBlock label="src/a.ts" lang="ts" lines={lines(3, 41)} totalLines={180} />)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(view.getByText('ts')).toBeTruthy()
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
})
it('omits the count note when the window is the whole file', () => {
const view = render(<ReadBlock label="a.ts" lines={lines(3)} totalLines={3} />)
expect(view.queryByText(//u)).toBeNull()
})
it('draws an empty label and empty language when neither is given', () => {
const view = render(<ReadBlock lines={lines(1)} totalLines={1} />)
expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
})
})
describe('ReadBlock height cap', () => {
it('renders every line and no expand control under the cap', () => {
const view = render(<ReadBlock label="a" lines={lines(4)} totalLines={4} maxLines={4} />)
expect(rowTexts(view.container)).toHaveLength(4)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const view = render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
// maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
const toggle = view.getByRole('button', { name: '展开其余 6 行' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('… 其余 6 行')
fireEvent.click(toggle)
expect(rowTexts(view.container)).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起内容' })
expect(collapse.getAttribute('aria-expanded')).toBe('true')
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<ReadBlock label="a" lines={lines(5)} totalLines={5} maxLines={1} />)
expect(gutters(view.container)).toEqual(['1'])
expect(view.getByRole('button', { name: '展开其余 4 行' })).toBeTruthy()
})
it('caps at the documented default when maxLines is absent', () => {
const view = render(
<ReadBlock label="a" lines={lines(DEFAULT_READ_MAX_LINES + 1)} totalLines={DEFAULT_READ_MAX_LINES + 1} />,
)
expect(rowTexts(view.container)).toHaveLength(DEFAULT_READ_MAX_LINES)
expect(view.getByRole('button', { name: '展开其余 1 行' })).toBeTruthy()
})
})
describe('ReadBlock copy', () => {
it('copies the raw window text, joined by newlines, never the gutter numbers', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ReadBlock label="a" lines={lines(3, 41)} totalLines={180} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('line 41\nline 42\nline 43')
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
// While the ok label is showing, further clicks are no-ops.
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
expect(writeText).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1000)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('copies the whole window while the height cap hides its middle', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith(lines(10).map(line => line.text).join('\n'))
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
})
it('does not claim success when the host refuses the write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<ReadBlock label="a" lines={lines(1)} totalLines={1} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
})
it('merges className onto the wrapper', () => {
const view = render(<ReadBlock className="x" label="a" lines={lines(1)} totalLines={1} />)
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
})
it('hides the copy control for an empty window so it cannot wipe the clipboard', () => {
// A successful read of an empty file settles to lines: [] with card:'read',
// so this branch is reachable; copying then would clear the clipboard.
const view = render(<ReadBlock label="empty.ts" lines={[]} totalLines={0} />)
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
})

View File

@@ -0,0 +1,214 @@
// @vitest-environment jsdom
// SearchBlock: both kinds (grouped grep matches and a flat glob path list), the
// folded truncation summary, the empty arm, per-file collapse/expand, the
// head/tail height cap and its expand control, the tail slice restoring its
// owning file header, and the copy control writing the whole structured
// result on both the accepted and refused clipboard paths.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts'
import type { SearchFileGroup } from '../src/index.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
/** The rendered result rows, one string per visible row (CSS-module class prefix). */
function lines(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
}
/** The file-group header rows, one string per header (path + count concatenated). */
function fileHeaders(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_fileHeader_"]')].map(row => row.textContent ?? '')
}
/** `count` numbered match lines under one file, without a terminating newline. */
function group(path: string, count: number, from = 1): SearchFileGroup {
return {
path,
matches: Array.from({ length: count }, (_v, i) => ({ lineNumber: from + i, line: `hit ${from + i}` })),
}
}
describe('SearchBlock matches kind', () => {
it('renders each file as a header group with its matched lines', () => {
const view = render(<SearchBlock kind="matches" truncated={false} total={3} files={[
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const a = 1' }, { lineNumber: 40, line: 'return a' }] },
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'const b = 2' }] },
]} />)
expect(fileHeaders(view.container)).toEqual(['a.ts2', 'b.ts1'])
expect(lines(view.container)).toEqual(['12: const a = 1', '40: return a', '7: const b = 2'])
// The summary counts matches and files, with no folded pre-cap total below the cap.
expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy()
expect(view.queryByText(/|/u)).toBeNull()
})
it('collapses and re-expands a single file group without touching the others', () => {
const view = render(<SearchBlock kind="matches" truncated={false} total={3} files={[
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] },
{ path: 'b.ts', matches: [{ lineNumber: 2, line: 'y' }] },
]} />)
const [headerA] = view.container.querySelectorAll('[class^="_fileHeader_"]')
expect(headerA!.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(headerA!)
// a.ts collapsed: its match row is gone, b.ts's stays.
expect(headerA!.getAttribute('aria-expanded')).toBe('false')
expect(lines(view.container)).toEqual(['2: y'])
fireEvent.click(headerA!)
expect(lines(view.container)).toEqual(['1: x', '2: y'])
})
it('folds the pre-cap total into the summary when truncated', () => {
const view = render(<SearchBlock kind="matches" truncated total={99} files={[group('a.ts', 2)]} />)
expect(view.getByText('显示 2 / 共 99 处匹配 · 1 个文件')).toBeTruthy()
})
})
describe('SearchBlock paths kind', () => {
it('renders a flat path list with a path-count summary', () => {
const view = render(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
expect(lines(view.container)).toEqual(['src/a.ts', 'src/b.ts'])
expect(view.getByText('2 个路径')).toBeTruthy()
// No file-group headers in the paths shape.
expect(fileHeaders(view.container)).toEqual([])
})
it('folds the pre-cap total into the paths summary when truncated', () => {
const view = render(<SearchBlock kind="paths" truncated total={50} paths={['a', 'b']} />)
expect(view.getByText('显示 2 / 共 50 个路径')).toBeTruthy()
})
})
describe('SearchBlock empty arm', () => {
it('shows the placeholder and no copy control for an empty matches result', () => {
const view = render(<SearchBlock kind="matches" truncated={false} total={0} files={[]} />)
expect(view.getByText('无结果')).toBeTruthy()
expect(view.queryByText('复制')).toBeNull()
expect(view.getByText('0 处匹配 · 0 个文件')).toBeTruthy()
})
it('shows the placeholder for an empty paths result', () => {
const view = render(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} />)
expect(view.getByText('无结果')).toBeTruthy()
expect(view.queryByText('复制')).toBeNull()
})
})
describe('SearchBlock height cap', () => {
it('renders every row and no expand control under the cap', () => {
const view = render(<SearchBlock kind="paths" truncated={false} total={4}
paths={['a', 'b', 'c', 'd']} maxLines={4} />)
expect(lines(view.container)).toHaveLength(4)
expect(view.container.querySelector('[aria-label^="展开"]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const paths = Array.from({ length: 10 }, (_v, i) => `p${i + 1}`)
const view = render(<SearchBlock kind="paths" truncated={false} total={10} paths={paths} maxLines={4} />)
// maxLines 4: head = ceil(4/2) = 2, tail = 2, 6 hidden.
expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10'])
const toggle = view.getByRole('button', { name: '展开其余 6 行结果' })
expect(toggle.textContent).toBe('… 其余 6 行')
fireEvent.click(toggle)
expect(lines(view.container)).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起结果' })
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10'])
})
it('counts a file header as one capped row alongside its matches', () => {
// One file with 10 matches → 11 rows (header + 10). Cap 4: head 2, tail 2.
const view = render(<SearchBlock kind="matches" truncated={false} total={10}
files={[group('a.ts', 10)]} maxLines={4} />)
// Head takes the header then the first match; tail takes the last two matches.
expect(lines(view.container)).toEqual(['1: hit 1', '9: hit 9', '10: hit 10'])
expect(fileHeaders(view.container)).toEqual(['a.ts10'])
expect(view.getByRole('button', { name: '展开其余 7 行结果' })).toBeTruthy()
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<SearchBlock kind="paths" truncated={false} total={5}
paths={['a', 'b', 'c', 'd', 'e']} maxLines={1} />)
expect(lines(view.container)).toEqual(['a'])
expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy()
})
it('restores the owning file header above a tail slice that begins mid-file', () => {
// Two files of 10 matches each → 22 rows. Cap 8: head 4 (a.ts header + 3
// matches), tail 4. The tail begins mid-b.ts, so its header is restored —
// and, being a row itself, it consumes one tail slot rather than pushing the
// card to 9 rows: the tail keeps its last 3 matches, total visible = 8.
const view = render(<SearchBlock kind="matches" truncated={false} total={20} maxLines={8} files={[
group('a.ts', 10), group('b.ts', 10, 11),
]} />)
expect(fileHeaders(view.container)).toEqual(['a.ts10', 'b.ts10'])
expect(lines(view.container)).toEqual([
'1: hit 1', '2: hit 2', '3: hit 3',
'18: hit 18', '19: hit 19', '20: hit 20',
])
// Visible rows hold at maxLines (2 headers + 6 matches = 8), so the hidden
// count stays exact: 22 8 = 14.
expect(view.getByRole('button', { name: '展开其余 14 行结果' })).toBeTruthy()
})
it('caps at the documented default when maxLines is absent', () => {
const paths = Array.from({ length: DEFAULT_SEARCH_MAX_LINES + 1 }, (_v, i) => `p${i}`)
const view = render(<SearchBlock kind="paths" truncated={false} total={paths.length} paths={paths} />)
expect(lines(view.container)).toHaveLength(DEFAULT_SEARCH_MAX_LINES)
expect(view.getByRole('button', { name: '展开其余 1 行结果' })).toBeTruthy()
})
})
describe('SearchBlock copy', () => {
it('copies the whole structured matches result, not the collapsed or capped view', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const view = render(<SearchBlock kind="matches" truncated total={9} maxLines={2} files={[
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }, { lineNumber: 2, line: 'y' }] },
{ path: 'b.ts', matches: [{ lineNumber: 3, line: 'z' }] },
]} />)
// Collapse a group and leave the cap in place: the clipboard still gets it all.
fireEvent.click(view.container.querySelector('[class^="_fileHeader_"]')!)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('a.ts\n1: x\n2: y\n\nb.ts\n3: z')
await act(async () => { await Promise.resolve() })
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
// A second click while the ok label shows is a no-op.
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
expect(writeText).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1000)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('copies the newline-joined path list for the paths shape', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('src/a.ts\nsrc/b.ts')
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
})
it('does not claim success when the host refuses the write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<SearchBlock kind="paths" truncated={false} total={1} paths={['a']} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
await act(async () => { await Promise.resolve() })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
})
it('merges className onto the wrapper and tags the wrapper with the kind', () => {
const view = render(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} className="x" />)
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
expect(view.container.firstElementChild?.getAttribute('data-search')).toBe('paths')
})
})

View File

@@ -1,11 +1,37 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
describe('Tooltip', () => {
it('can delay pointer hover without delaying keyboard focus', () => {
vi.useFakeTimers()
try {
render(
<Tooltip label="Timing details" delayMs={500}>
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
fireEvent.mouseEnter(anchor)
act(() => { vi.advanceTimersByTime(499) })
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseLeave(anchor)
act(() => { vi.advanceTimersByTime(1) })
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseEnter(anchor)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
fireEvent.mouseLeave(anchor)
fireEvent.focus(anchor)
expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
} finally {
vi.useRealTimers()
}
})
it('shows the bubble to the right on hover and hides it on leave', () => {
render(
<Tooltip label="Open sidebar">

View File

@@ -0,0 +1,211 @@
// @vitest-environment jsdom
// WebBlock: both kinds of the web card. The search card's answer, its citation
// list with the title-or-hostname label fallback and optional snippet/date, the
// source-list height cap and its expand control, and the truncated indicator;
// the fetch card's linked URL, status, and truncation. Safe-link attributes on
// both kinds: an http(s) URL becomes an external anchor (target/rel), any other
// URL renders as plain text with no href.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts'
import type { WebSourceView } from '../src/index.ts'
afterEach(cleanup)
/** `count` sources with sequential hostnames, so the cap slices read distinctly. */
function sources(count: number): WebSourceView[] {
return Array.from({ length: count }, (_value, index) => ({
url: `https://site-${index}.example.com/page`,
title: `Source ${index}`,
}))
}
describe('WebBlock search card', () => {
it('renders the answer above the citation list', () => {
const view = render(<WebBlock kind="search" answer="**Answer** text" sources={sources(2)} truncated={false} />)
expect(view.getByText('Answer')).toBeTruthy()
expect(view.getByText('Source 0')).toBeTruthy()
expect(view.getByText('Source 1')).toBeTruthy()
})
it('omits the answer block when there is no answer', () => {
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(view.container.querySelector('[class^="_answer_"]')).toBeNull()
const empty = render(<WebBlock kind="search" answer="" sources={sources(1)} truncated={false} />)
expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull()
})
it('shows the empty-state note when a search returns no answer and no sources', () => {
const view = render(<WebBlock kind="search" sources={[]} truncated={false} />)
expect(view.getByText('未找到结果')).toBeTruthy()
// The empty note replaces the source list, not an empty <ol>.
expect(view.container.querySelector('ol')).toBeNull()
})
it('shows the source list, not the empty note, when a source is present', () => {
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(view.container.querySelector('ol')).toBeTruthy()
expect(view.queryByText('未找到结果')).toBeNull()
})
it('shows the source list when an empty source list still carries an answer', () => {
const view = render(<WebBlock kind="search" answer="Just an answer" sources={[]} truncated={false} />)
expect(view.getByText('Just an answer')).toBeTruthy()
expect(view.queryByText('未找到结果')).toBeNull()
})
it('labels a source by its title, and by hostname when the title is absent', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://example.com/a', title: 'Titled' },
{ url: 'https://plain.example.org/b' },
{ url: 'https://empty.example.net/c', title: '' },
]} />)
expect(view.getByText('Titled')).toBeTruthy()
// No title / empty title: the hostname labels the link.
expect(view.getByText('plain.example.org')).toBeTruthy()
expect(view.getByText('empty.example.net')).toBeTruthy()
})
it('labels a source by the raw url when it parses to an empty hostname', () => {
// file:/data:/javascript: URLs parse but have no hostname; the label must
// fall back to the raw URL so it is never blank (and the link stays plain
// text since the protocol is not http(s)).
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'file:///etc/passwd' },
]} />)
expect(view.getByText('file:///etc/passwd')).toBeTruthy()
})
it('renders a source as a safe external anchor for an http(s) url', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://example.com/a', title: 'Titled' },
]} />)
const anchor = view.getByText('Titled') as HTMLAnchorElement
expect(anchor.tagName).toBe('A')
expect(anchor.getAttribute('href')).toBe('https://example.com/a')
expect(anchor.getAttribute('target')).toBe('_blank')
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
})
it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'javascript:alert(1)', title: 'Dangerous' },
{ url: 'not a url' },
]} />)
const unsafe = view.getByText('Dangerous')
expect(unsafe.tagName).toBe('SPAN')
expect(unsafe.getAttribute('href')).toBeNull()
// An unparseable url is not a link and cannot yield a hostname, so its raw
// text is the label.
const raw = view.getByText('not a url')
expect(raw.tagName).toBe('SPAN')
})
it('shows a source snippet and publication date when present, and omits them when absent or empty', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://a.example.com', title: 'A', snippet: 'excerpt', publishedAt: '2026-07-01' },
{ url: 'https://b.example.com', title: 'B', snippet: '', publishedAt: '' },
{ url: 'https://c.example.com', title: 'C' },
]} />)
expect(view.getByText('excerpt')).toBeTruthy()
expect(view.getByText('2026-07-01')).toBeTruthy()
// The empty-string and absent arms both draw nothing beyond the link.
expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1)
expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1)
})
it('shows the truncated indicator only when the list was capped by the tool', () => {
const on = render(<WebBlock kind="search" sources={sources(1)} truncated />)
expect(on.getByText('来源列表已截断')).toBeTruthy()
cleanup()
const off = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(off.queryByText('来源列表已截断')).toBeNull()
})
it('renders every source and no expand control under the cap', () => {
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
// maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
.toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('… 其余 6 条来源')
fireEvent.click(toggle)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起来源' })
expect(collapse.getAttribute('aria-expanded')).toBe('true')
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
})
it('numbers a collapsed tail by each source original position, not its visible slot', () => {
// maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read
// as citations 9 and 10 (via <li value>), not renumbered 3 and 4.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10'])
})
it('keeps the expander out of the ordered-list numbering', () => {
// The expander is a marker-less <li>, so it is valid inside <ol> and does not
// consume a citation number between the head and tail sources.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
const ol = view.container.querySelector('ol')!
// Every direct child is an <li> (no bare <button> child — invalid HTML).
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
})
it('caps at the documented default when maxSources is absent', () => {
const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
})
})
describe('WebBlock fetch card', () => {
it('renders the fetched url as a safe external anchor and its HTTP status', () => {
const view = render(<WebBlock kind="fetch" url="https://example.com/page" statusCode={200} truncated={false} />)
const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement
expect(anchor.tagName).toBe('A')
expect(anchor.getAttribute('href')).toBe('https://example.com/page')
expect(anchor.getAttribute('target')).toBe('_blank')
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
expect(view.getByText('HTTP 200')).toBeTruthy()
})
it('renders a non-http fetch url as plain text with no href', () => {
const view = render(<WebBlock kind="fetch" url="file:///etc/passwd" statusCode={200} truncated={false} />)
const label = view.getByText('file:///etc/passwd')
expect(label.tagName).toBe('SPAN')
expect(label.getAttribute('href')).toBeNull()
})
it('shows the truncated indicator only when the content was cut', () => {
const on = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated />)
expect(on.getByText('内容已截断')).toBeTruthy()
cleanup()
const off = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated={false} />)
expect(off.queryByText('内容已截断')).toBeNull()
})
it('carries a non-200 status verbatim', () => {
const view = render(<WebBlock kind="fetch" url="https://example.com/missing" statusCode={404} truncated={false} />)
expect(view.getByText('HTTP 404')).toBeTruthy()
})
})