Merge remote-tracking branch 'origin/master' into fix/workspace-context-rendered-change-proof

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml
#	packages/context/workspace-context/README.i18n.yaml
#	packages/context/workspace-context/tests/workspace-context.spec.ts
This commit is contained in:
ZiyaZhang
2026-08-06 02:06:16 -07:00
3854 changed files with 218071 additions and 54785 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,9 @@
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, IconGoalOutline16, IconSendOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
@@ -14,8 +16,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 (46 deepsuite + 17 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(64)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
@@ -36,11 +38,19 @@ 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')
})
it('renders reusable goal glyphs without document-global ids', () => {
const { container } = render(<><IconGoalOutline16 /><IconGoalOutline16 /></>)
expect(container.querySelector('[id]')).toBeNull()
expect(container.querySelector('[clip-path]')).toBeNull()
})
})

View File

@@ -1,7 +1,10 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { Extension } from 'micromark-util-types'
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { remarkCjkFriendlyStrong } from '../src/markdown/remarkCjkFriendlyStrong.ts'
import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
afterEach(cleanup)
@@ -66,6 +69,104 @@ describe('MarkdownText', () => {
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
})
it('closes punctuation-terminated strong emphasis before adjacent CJK text', () => {
const cases = [
['**注意:**内容', '注意:'],
['**Notice:**内容', 'Notice:'],
['**事件中间件waterfall**实现', '事件中间件waterfall'],
['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'],
['**句号。**后续', '句号。'],
['**Period.**后续', 'Period.'],
['**提醒!**继续', '提醒!'],
['**Warning!**继续', 'Warning!'],
] as const
const source = cases.map(([markdown]) => markdown).join('\n\n')
for (const streaming of [false, true]) {
const rendered = render(<MarkdownText text={source} streaming={streaming} />)
expect([...rendered.container.querySelectorAll('strong')].map(node => node.textContent))
.toEqual(cases.map(([, strong]) => strong))
rendered.unmount()
}
})
it('keeps the CJK strong extension out of escaped, code, math, and ASCII contexts', () => {
const source = [
String.raw`\**注意:**内容`,
'`**注意:**内容`',
'**Notice:**text',
'*提醒!*继续',
'$**注意:**内容$',
'```md',
'**注意:**内容',
'```',
'**普通**内容',
'*普通*内容',
].join('\n\n')
const { container } = render(<MarkdownText text={source} />)
expect([...container.querySelectorAll('strong')].map(node => node.textContent)).toEqual(['普通'])
expect([...container.querySelectorAll('em')].map(node => node.textContent)).toEqual(['普通'])
expect(container.querySelector('code')?.textContent).toBe('**注意:**内容')
expect(container.querySelector('.katex annotation')?.textContent).toBe('**注意:**内容')
expect(container.querySelector('pre code')?.textContent).toContain('**注意:**内容')
expect(container.textContent).toContain('**Notice:**text')
expect(container.textContent).toContain('*提醒!*继续')
expect(container.textContent).toContain('**注意:**内容')
})
it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => {
const localUrl = 'http://127.0.0.1:3199/?demo=1'
const remoteUrl = 'https://example.com/preview?q=one%20two#result'
const source = [
`\`${localUrl}\``,
`\`${remoteUrl}\``,
'`curl http://127.0.0.1:3199/?demo=1`',
'`javascript:alert(1)`',
'`mailto:dev@example.com`',
`\` ${localUrl} \``,
'```',
localUrl,
'```',
].join('\n\n')
const { container } = render(<MarkdownText text={source} />)
const links = screen.getAllByRole('link')
expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl])
for (const link of links) {
expect(link.closest('code')).not.toBeNull()
expect(link.getAttribute('target')).toBe('_blank')
expect(link.getAttribute('rel')).toBe('noopener noreferrer')
}
links[0]?.focus()
expect(document.activeElement).toBe(links[0])
expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull()
expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull()
expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull()
const paddedCode = [...container.querySelectorAll('code')]
.find(code => code.textContent === ` ${localUrl} `)
expect(paddedCode?.querySelector('a')).toBeNull()
expect(container.querySelector('pre code a')).toBeNull()
})
it('registers the CJK strong extension and rejects a parser without CommonMark attention markers', () => {
const data: { micromarkExtensions?: Extension[] } = {}
const processor = { data: () => data }
remarkCjkFriendlyStrong.call(processor)
remarkCjkFriendlyStrong.call(processor)
expect(data.micromarkExtensions).toHaveLength(2)
const construct = data.micromarkExtensions?.[0]?.text?.[42]
const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize
expect(tokenizer).toBeTypeOf('function')
expect(() => tokenizer?.call({
parser: { constructs: { attentionMarkers: {} } },
previous: null,
}, {}, () => undefined, () => undefined)).toThrow(
'micromark CommonMark attention markers are unavailable',
)
})
it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
const { container, unmount } = render(<MarkdownText text={'```' + label + '\ncode body\n```'} />)
@@ -94,13 +195,40 @@ describe('MarkdownText', () => {
expect(done.container.querySelector('pre.shiki')).not.toBeNull()
})
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
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)',
'![plain diagram](http://example.com/plain.png)',
].join('\n\n')
const { container } = render(<MarkdownText text={markdown} />)
const images = [...container.querySelectorAll('img')]
expect(images.map(image => image.getAttribute('src'))).toEqual([
'https://example.com/secure.png',
'http://example.com/plain.png',
])
for (const image of images) {
expect(image.getAttribute('loading')).toBe('lazy')
expect(image.getAttribute('decoding')).toBe('async')
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
}
})
it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => {
const markdown = [
'<script>globalThis.compromised = true</script>',
'<img src="x" onerror="globalThis.compromised = true">',
'[script](javascript:alert(1)) [relative](/settings)',
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
'![remote diagram](https://example.com/private.png)',
'![relative diagram](private.png)',
'![absolute diagram](/workspace/private.png)',
'![file diagram](file:///workspace/private.png)',
'![script diagram](javascript:alert(1))',
'![mail diagram](mailto:dev@example.com)',
].join('\n\n')
const { container } = render(<MarkdownText text={markdown} />)
@@ -112,7 +240,11 @@ describe('MarkdownText', () => {
expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
expect(screen.getByText('remote diagram')).toBeTruthy()
expect(screen.getByText('relative diagram')).toBeTruthy()
expect(screen.getByText('absolute diagram')).toBeTruthy()
expect(screen.getByText('file diagram')).toBeTruthy()
expect(screen.getByText('script diagram')).toBeTruthy()
expect(screen.getByText('mail diagram')).toBeTruthy()
})
it('keeps incomplete streaming Markdown renderable', () => {
@@ -121,6 +253,194 @@ 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('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => {
const source = [
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
'',
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
].join('\n')
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex')).toHaveLength(6)
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}')
expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent)
.toContain('\\tag{1}')
expect(container.querySelector('.katex-error')).toBeNull()
expect(container.querySelector('table .katex')).not.toBeNull()
})
it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => {
const cases = [
{
source: '\\(\\alpha \\, \\beta\\)',
math: 1,
display: 0,
},
{
source: String.raw`\\\(x\)`,
math: 1,
display: 0,
value: 'x',
},
{
source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)',
math: 1,
display: 0,
value: '\\frac{1}{5}\n+\\frac{1}{7}',
},
{
source: '\\[a\\\\\nb\\]',
math: 1,
display: 1,
value: 'a\\\\\nb',
},
{
source: '> \\[\n> \\frac{1}{5}\n> \\]',
math: 1,
display: 1,
},
{
source: '- \\[\n \\frac{1}{5}\n \\]',
math: 1,
display: 1,
},
]
for (const item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
if ('value' in item) {
expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value)
}
rendered.unmount()
}
const literal = render(<MarkdownText text={'\\\\(x\\)\n\n\\[x'} />)
expect(literal.container.querySelectorAll('.katex')).toHaveLength(0)
expect(literal.container.querySelector('.katex-display')).toBeNull()
expect(literal.container.textContent).toContain('[x')
})
it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => {
const cases = [
{ source: '$$\n\\theta\n$$', math: 1, display: 1 },
{ source: '$$$\\theta$$$', math: 1, display: 0 },
{ source: '$$a$b\nc', math: 0, display: 0 },
{ source: ' \\[\n \\theta\n \\]', math: 1, display: 1 },
{ source: '\\(\\theta', math: 0, display: 0 },
{ source: String.raw`\(a\\)`, math: 0, display: 0 },
{ source: '\\[\n\\[', math: 0, display: 0 },
{ source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 },
]
for (const item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
rendered.unmount()
}
})
it('lets display math interrupt an open paragraph', () => {
for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('p')).toHaveLength(1)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
rendered.unmount()
}
})
it('leaves a dollar block with trailing text to upstream inline math', () => {
const { container } = render(<MarkdownText text="$$x$$ trailing" />)
expect(container.querySelectorAll('.katex')).toHaveLength(1)
expect(container.querySelector('.katex-display')).toBeNull()
expect(container.querySelector('annotation')?.textContent).toBe('x')
expect(container.textContent).toContain('trailing')
})
it('renders escaped dollars and even backslash pairs before closing fences', () => {
const source = [
String.raw`$$100\$$$`,
'',
String.raw`\(a\\\)`,
'',
String.raw`\[b\\\]`,
].join('\n')
const { container } = render(<MarkdownText text={source} />)
const values = [...container.querySelectorAll('annotation')].map(node => node.textContent)
expect(values).toEqual([String.raw`100\$`, String.raw`a\\`, String.raw`b\\`])
expect(container.querySelector('.katex-error')).toBeNull()
})
it('bounds fallback work for repeated unclosed backslash delimiters', () => {
const startedAt = performance.now()
const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
expect(performance.now() - startedAt).toBeLessThan(1_000)
expect(container.querySelector('.katex')).toBeNull()
})
it('leaves TeX-looking fenced code literal', () => {
const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```'
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelector('.katex')).toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('\\[\\frac{1}{5}\\]')
expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$')
})
it('registers the compatibility extension on a bare remark processor', () => {
const data: { micromarkExtensions?: Extension[] } = {}
remarkMathCompatibility.call({ data: () => data })
expect(data.micromarkExtensions).toHaveLength(1)
})
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">
@@ -17,8 +43,9 @@ describe('Tooltip', () => {
const bubble = screen.getByRole('tooltip')
expect(bubble.textContent).toBe('Open sidebar')
expect(bubble.getAttribute('data-side')).toBe('right')
// jsdom rects are all-zero: right placement lands at the +10 gutter.
expect(bubble.style.left).toBe('10px')
// jsdom rects are all-zero: right placement lands at the +10 gutter, then
// the zero-width measured rect clamps to the 12px edge margin (10 + 12).
expect(bubble.style.left).toBe('22px')
expect(bubble.style.top).toBe('0px')
fireEvent.mouseLeave(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
@@ -34,12 +61,100 @@ describe('Tooltip', () => {
fireEvent.focus(anchor)
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('bottom')
expect(bubble.style.left).toBe('0px')
// Zero-width jsdom rect at x=0 clamps to the 12px edge margin.
expect(bubble.style.left).toBe('12px')
expect(bubble.style.top).toBe('8px')
fireEvent.blur(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
})
// jsdom's default rects are all-zero, so the clamp tests stub the measured
// rect (anchor and bubble share the prototype stub) and derive expectations
// from it: pos.x = anchor center, then shifted by the measured overflow.
const rect = (left: number, right: number): DOMRect =>
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
it('clamps a bubble overflowing the right viewport edge back inside', () => {
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
try {
render(
<Tooltip label="Wide" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
// pos.x = 1000 (anchor center); measured right edge 1100 overflows the
// 1024 viewport's 12px safe margin (limit 1012) by 88, so the clamp
// shifts left to 912.
expect(screen.getByRole('tooltip').style.left).toBe('912px')
} finally {
spy.mockRestore()
}
})
it('reclamps after label and viewport width changes', () => {
const originalWidth = window.innerWidth
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.getAttribute('role') !== 'tooltip') return rect(900, 1000)
return this.textContent === 'Wide' ? rect(900, 1100) : rect(850, 950)
})
try {
const view = render(
<Tooltip label="Wide" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
expect(screen.getByRole('tooltip').style.left).toBe('862px')
view.rerender(
<Tooltip label="Short" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
expect(screen.getByRole('tooltip').style.left).toBe('950px')
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 900 })
fireEvent(window, new Event('resize'))
expect(screen.getByRole('tooltip').style.left).toBe('888px')
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
spy.mockRestore()
}
})
it('clamps a bubble past the left viewport edge back inside', () => {
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80))
try {
render(
<Tooltip label="Wide" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
// pos.x = 30 (anchor center); measured left edge -20 underflows the
// 12px safe margin by 32, so the clamp shifts right to 62.
expect(screen.getByRole('tooltip').style.left).toBe('62px')
} finally {
spy.mockRestore()
}
})
it('supports top placement for anchors at the viewport bottom', () => {
render(
<Tooltip label="Above" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('top')
// jsdom rects are all-zero: top placement lands at the -8 gutter and the
// zero-width measured rect clamps left to the 12px edge margin.
expect(bubble.style.left).toBe('12px')
expect(bubble.style.top).toBe('-8px')
})
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
const onMouseEnter = vi.fn()
const onMouseLeave = vi.fn()

View File

@@ -0,0 +1,178 @@
// @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
// full source list under one <ol>, 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, render } from '@testing-library/react'
import { WebBlock } from '../src/index.ts'
import type { WebSourceView } from '../src/index.ts'
afterEach(cleanup)
/** `count` sources with sequential hostnames, so each row reads 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 in one <ol> with no expand control', () => {
// The card shows the whole list the tool returned, with no head/tail
// collapse and no expand button. jsdom does not resolve the CSS Modules
// layout, so the scroll geometry the `.sources` max-height produces is
// pinned by the assembled browser case in apps/web/tests/web-search-round.e2e.ts,
// not here.
const view = render(<WebBlock kind="search" sources={sources(30)} truncated={false} />)
expect(view.container.querySelectorAll('li[class^="_source_"]')).toHaveLength(30)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.container.querySelector('button')).toBeNull()
// Every direct child of the <ol> is a source <li> (no marker-less expander).
const ol = view.container.querySelector('ol')!
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
})
it('numbers every source by its 1-based citation index via <li value>', () => {
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} />)
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '3', '4'])
})
})
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()
})
})