// @vitest-environment jsdom // Remaining chat branch tails: MessageItem context/unknown arms, // user IconActions, StatsLine no-cache join, // AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live // with the keyed-slot machinery specs since the tool ring dissolved into // renderSlot.) import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ChatConversationViewNode, ConversationNode, } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatNodeViewProps } from '../src/client/contract/slots.ts' import { formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, } from '../src/client/chat/message-chrome.ts' import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { zh } from '../src/client/locales.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ class ResizeObserverStub { observe(): void {} unobserve(): void {} disconnect(): void {} } beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) afterEach(() => { cleanup() vi.useRealTimers() vi.unstubAllGlobals() }) // Mirrors the real lookup chain (conversation namespace, then common). const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) const RETRY_ID = 'retry-fixture' as Extract['retryId'] interface MessageItemProps { readonly node: ConversationNode readonly t: ChatNodeViewProps['t'] } /** Legacy-node fixture adapter for the independently registered renderers. */ function MessageItem({ node, t: translate }: MessageItemProps) { const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind const viewNode: ChatConversationViewNode = { key: `fixture:${node.kind}:${node.seq}`, kind, id: String(node.seq), target: 'chat', anchorSeq: node.seq, location: { kind: 'session' }, visibility: 'visible', data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node, } const props = { node: viewNode, t: translate } as ChatNodeViewProps switch (node.kind) { case 'user': case 'steering': return } /> case 'context': return } /> case 'compaction': return } /> case 'model-retry': return } /> case 'unknown': return } /> default: throw new Error(`unsupported MessageItem fixture kind: ${node.kind}`) } } describe('MessageItem arms', () => { it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) // Same-day clock: construct "today at 14:24" so the label stays `HH:mm`. const now = new Date() const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() render( , ) expect(screen.getByText('14:24')).toBeTruthy() expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() expect(screen.queryByRole('button', { name: '在新对话中分支' })).toBeNull() expect(screen.queryByRole('button', { name: '编辑' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('hello bubble') }) it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined, }) const exec = vi.fn().mockReturnValue(true) Object.defineProperty(document, 'execCommand', { configurable: true, value: exec, }) render( , ) fireEvent.click(screen.getByRole('button', { name: '复制' })) expect(exec).toHaveBeenCalledWith('copy') }) it('user copy never claims success when the host rejects the write', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, }) render( , ) fireEvent.click(screen.getByRole('button', { name: '复制' })) await act(async () => { await Promise.resolve() await Promise.resolve() }) expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull() }) it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => { vi.useFakeTimers() const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) render( , ) const copy = screen.getByRole('button', { name: '复制' }) fireEvent.click(copy) fireEvent.click(copy) expect(writeText).toHaveBeenCalledTimes(1) // Two microtask ticks: writeClipboard's own await, then the .then that // lands the success chrome. await act(async () => { await Promise.resolve() await Promise.resolve() }) const done = screen.getByRole('button', { name: '复制成功' }) fireEvent.click(done) expect(writeText).toHaveBeenCalledTimes(1) act(() => { vi.advanceTimersByTime(1000) }) expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() }) it('clears copy feedback work when the message unmounts', async () => { vi.useFakeTimers() let finishWrite!: () => void const writeText = vi.fn(() => new Promise((resolve) => { finishWrite = resolve })) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) const view = render( , ) fireEvent.click(screen.getByRole('button', { name: '复制' })) view.unmount() await act(async () => { finishWrite() await Promise.resolve() await Promise.resolve() }) expect(vi.getTimerCount()).toBe(0) const mounted = render( , ) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) }, }) fireEvent.click(screen.getByRole('button', { name: '复制' })) await act(async () => { await Promise.resolve() await Promise.resolve() }) expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() mounted.unmount() expect(vi.getTimerCount()).toBe(0) }) it('consumed steering renders as a plain user bubble and keeps copy without branch', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) const view = render( , ) expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('steer!') expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() }) it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => { const ctxView = render( , ) const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() expect(ctxView.container.querySelector('svg')).not.toBeNull() fireEvent.click(disclosure) expect(disclosure.getAttribute('aria-expanded')).toBe('true') // An unknown form renders the opaque body: the model-facing text keeps its // real line breaks instead of being escaped into one JSON line, and the // remaining source data follows it as fields. expect(ctxView.container.querySelector('[data-context-text]')?.textContent) .toBe('line one\n\nline two') const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent) expect(fields).toEqual(['plugin', 'empty', 'list']) fireEvent.keyDown(disclosure, { key: ' ' }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') }) it('the instructions form names the files it reconciled above their text', () => { const view = render( \nInstructions from: AGENTS.md\n' }], source: { kind: 'workspace-instructions', form: 'instructions', baseline: true, changes: [ { action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' }, { action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' }, { action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' }, ], }, provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' }, form: 'instructions', } as never} />, ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ })) const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除']) // The `` framing is part of what the model read, so the // body keeps it verbatim rather than presenting a cleaned-up excerpt. expect(view.container.querySelector('[data-context-text]')?.textContent) .toContain('') }) it('a delta distinguishes a newly reconciled file from a rewritten one', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ })) const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新']) }) it('keeps an interleaved unknown block in the order the model received it', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: '上下文注入' })) const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent) expect(texts).toEqual(['before', 'after']) expect(view.getByText(/未知内容块/)).toBeTruthy() }) it('the catalog form lists its durable entries instead of the model-facing prose', () => { const view = render( \n\n- `a`: A\n' }], source: { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }], }, provenance: { role: 'inject', label: 'skill-catalog' }, form: 'catalog', } as never} />, ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent) expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B']) expect(view.container.querySelector('[data-context-text]')).toBeNull() expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull() }) it('a replacement catalog says so above its entries', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录') }) it('a partially unreadable catalog falls back whole rather than showing a short list', () => { // All-or-nothing: a body that replaces the model-facing text must not show // a confident, incomplete account of what the model read. const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) expect(view.container.querySelector('[data-context-entries]')).toBeNull() expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') // The marker reports what rendered, not what was declared. expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) .toBeNull() }) it('an unreadable instruction list falls back to the opaque body with its fields', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ })) expect(view.container.querySelector('[data-context-files]')).toBeNull() expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose') expect(view.container.querySelector('[data-context-fields]')).not.toBeNull() }) it('joins adjacent text blocks the way a provider adapter flattens them', () => { // No invented separator: showing a line break the model never saw would // misreport the request. const view = render( , ) fireEvent.click(view.getByRole('button', { name: '上下文注入' })) expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond') }) it('bounds an oversized source field, not only the model-facing text', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ })) expect(view.container.querySelector('[data-context-fields] dd')?.textContent) .toMatch(/… 已截断,共 \d+ 字符$/) }) it('an empty replacement catalog stays a catalog: it retires every earlier name', () => { // `renderCatalogUpdate` legitimately publishes zero entries when the last // skill disappears; falling back would hide that the catalog was cleared. const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录') expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0) expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) .toBe('catalog') }) it('a catalog whose entries are unreadable falls back to the opaque body', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) expect(view.container.querySelector('[data-context-entries]')).toBeNull() expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') }) it('bounds a large catalog and says how many rows it withheld', () => { const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' })) const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200) expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条') }) it('a catalog keeps a content block this version does not know', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) expect(view.getByText(/未知内容块/)).toBeTruthy() }) it('an instruction change with an unrecognized action falls back whole', () => { // The action decides the word the row shows, so an unknown one cannot be // presented as loaded or updated. const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ })) expect(view.container.querySelector('[data-context-files]')).toBeNull() expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose') }) it('the opaque fallback keeps a form declaration this version cannot present', () => { // Otherwise a newer or foreign log's declared shape vanishes from the UI. const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ })) const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent) expect(fields).toEqual(['plugin', 'form']) }) it('the snapshot form attributes each part to the subsystem that produced it', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ })) const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent) expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo']) }) it('a notice puts its account on the collapsed row', () => { // The whole point of the form: readable without expanding. const view = render( , ) expect(view.container.querySelector('[data-context-summary]')?.textContent) .toBe('bash pnpm test [status: completed]') expect(view.container.querySelector('[data-context-injection-body]')).toBeNull() }) it('a notice without its account falls back to the opaque body', () => { const view = render( , ) expect(view.container.querySelector('[data-context-summary]')).toBeNull() fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-tasks$/ })) expect(view.container.querySelector('[data-context-fields]')).not.toBeNull() }) it('each form falls back to the opaque body when its required facts are unreadable', () => { // The fallback chain is the load-bearing wall: every dedicated form must // reach it, and the row marker must not claim a form that did not render. const cases = [ { form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' }, { form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' }, { form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' }, ] as const for (const { form, source, label } of cases) { cleanup() const view = render( , ) fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) })) expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`) expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) .toBeNull() } }) it('a snapshot states the supersession its framing line carries', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ })) expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent) .toBe('取代先前的快照') }) it('a relay names the agent that sent it above what it said', () => { const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ })) expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7') expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body') }) it('a recall reports how much of each source session survived the read', () => { // Recalled context is bounded on the way in, so hiding the omitted count // would overstate what the model received. const view = render( , ) fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ })) const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent) expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条']) expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material') }) it('unknown nodes retain the generic JSON row', () => { const unknownView = render( , ) expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy() }) it('a compaction marker discloses its summary and never shows the framed checkpoint', () => { const view = render( , ) const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row.getAttribute('aria-expanded')).toBe('false') expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() expect(view.queryByText(/保留的事实/)).toBeNull() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') expect(view.getByRole('heading', { name: '摘要标题' })).toBeTruthy() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('false') }) it('a marker whose cited summary event fell outside the window is not expandable', () => { const view = render() const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row).toHaveProperty('disabled', true) expect(row.getAttribute('aria-expanded')).toBeNull() expect(view.getByText('压缩摘要不可用')).toBeTruthy() fireEvent.click(row) // a disabled control stays collapsed expect(row.getAttribute('aria-expanded')).toBeNull() }) it('collapses retry details behind the durable model retry status', () => { vi.useFakeTimers() vi.setSystemTime(10_000) const view = render( , ) const details = view.container.querySelector('details') const summary = view.container.querySelector('summary') expect(details?.open).toBe(false) expect(details?.dataset.active).toBe('true') expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 3s') expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms') expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置') act(() => { vi.advanceTimersByTime(1_100) }) expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 2s') act(() => { vi.advanceTimersByTime(1_000) }) expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') view.rerender( , ) expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 4s') if (summary === null) throw new Error('retry summary missing') fireEvent.click(summary) expect(details?.open).toBe(true) view.rerender( , ) expect(details?.dataset.active).toBeUndefined() expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s') view.rerender( , ) expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s') view.rerender( , ) expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s') }) }) describe('formatMessageClock', () => { const now = new Date(2026, 6, 29, 10, 0).getTime() it('keeps HH:mm on the same calendar day', () => { expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), t, now)).toBe('14:24') }) it('prefixes month and day across days in the same year', () => { expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), t, now)).toBe('1月1日 14:24') }) it('prefixes year, month, and day across years', () => { expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), t, now)).toBe('2025年12月31日 09:05') }) it('arms the next local midnight from an in-day instant', () => { const noon = new Date(2026, 6, 29, 12, 0).getTime() expect(startOfLocalDay(noon)).toBe(new Date(2026, 6, 29).getTime()) expect(msUntilNextLocalMidnight(noon)).toBe(12 * 3_600_000) }) }) describe('useCalendarDay boundary refresh', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('widens a same-day user clock after local midnight', () => { const dayStart = new Date(2026, 6, 29, 23, 50).getTime() vi.setSystemTime(dayStart) const time = new Date(2026, 6, 29, 14, 24).getTime() render( , ) expect(screen.getByText('14:24')).toBeTruthy() act(() => { vi.advanceTimersByTime(msUntilNextLocalMidnight(dayStart) + 1) }) expect(screen.getByText('7月29日 14:24')).toBeTruthy() }) }) describe('small branch tails', () => { it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { const view = render( , ) expect(view.getByText('one-liner')).toBeTruthy() }) it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => { // Cache hit is null only when all three prompt buckets are zero (pure // output accounting) — any billed input makes it a real 0%. const nodes = [{ kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 }, }] as const const snap = { chat: chatSnapshotFixture({ nodes }), nodes } const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( key === 'tokenUsage' ? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 } : undefined} />, ) expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok') }) })