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:
@@ -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' })
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
182
packages/client/ui-primitives/tests/diff-block.spec.tsx
Normal file
182
packages/client/ui-primitives/tests/diff-block.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
'',
|
||||
'',
|
||||
].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)',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
')',
|
||||
'',
|
||||
].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', () => {
|
||||
|
||||
241
packages/client/ui-primitives/tests/read-block.spec.tsx
Normal file
241
packages/client/ui-primitives/tests/read-block.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
214
packages/client/ui-primitives/tests/search-block.spec.tsx
Normal file
214
packages/client/ui-primitives/tests/search-block.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
178
packages/client/ui-primitives/tests/web-block.spec.tsx
Normal file
178
packages/client/ui-primitives/tests/web-block.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user