mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Address the ds-review-bot findings on the search card: - searchCardModel dropped the result view's `content`, so a capped search's `Full … stored at: <locator>` recovery footer vanished from the UI (the card replaces the raw text). Thread it through as `SearchCardModel.recovery` and render it below the card at all three sites, only when truncated. - SearchRow's fallback body was gated on `state === 'error'`, so a settled non-error call with no card (a successful nested run_code sub-dispatch, a legacy generic result) showed only its summary with content lost. Widen it to any settled call with `search === null`. - searchCardModel trusted the `files`/`paths` shape the host wire schema only string-checks; a malformed known-kind frame would crash SearchBlock. Validate the full shape and fall to the generic path on mismatch. - SearchBlock's restored tail file header added a row without consuming a tail slot, exceeding maxLines by one and overstating the hidden count. Make it consume a slot so the visible count holds at maxLines and `hidden` stays exact. Correct the fixture JSDoc (now genuinely exceeds the row cap) and the Agent Note recovery-text claim, sync the ui-conversation bilingual README with the search row, and add an assembled keyless snapshot (apps/web/tests/search-card.snapshot.ts) that pins the grep card's shape from the built bundles.
215 lines
11 KiB
TypeScript
215 lines
11 KiB
TypeScript
// @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')
|
||
})
|
||
})
|