// @vitest-environment jsdom // Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx // acceptance flows), four-share props form: breadcrumb ancestry derivation + // error strip in ConversationRoot, DetailsPanel non-JSON args / non-text // result blocks / error-only results over the shared store, EmptyState // failure surface and path-modal confirm with in-component cwd derivation. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { hookOf } from './hook.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' // Export discipline: packages/client/AGENTS.md. import { createChatStore } from '../src/client/stores.ts' import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' import { EmptyState } from '../src/client/skeleton/EmptyState.tsx' afterEach(cleanup) const SID = 's1' as SessionId /** Fallback-only chain stub (no takeover registered in these benches). */ const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] = (_key, _owner, opts) => opts?.fallback ?? null function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, } as ConversationSnapshot } function sessionSource(over?: Partial) { const snap = { ...snapshotBase(), ...over } return { getSnapshot: () => snap, subscribe: () => () => {}, } } /** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) { const store = createSnapshotStore({ ids: rows.map(r => r.id as SessionId), byId: Object.fromEntries(rows.map(r => [r.id, { id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1, ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), ...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}), }])), current: undefined, } as SessionListState) return hookOf(store) } describe('ConversationRoot branches', () => { const chatTab: ViewTab = { id: 'chat', label: 'Chat' } /** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */ const stubRenderSlot = (() =>
) as unknown as ConversationRootProps['renderSlot'] /** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */ const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)} function rootProps(over?: { rows?: { id: string; title: string; parentId?: string }[] snapshot?: Partial }) { const open = vi.fn() const chat = createChatStore().create() const view = render( } useSessions={listHook(over?.rows ?? [])} useStore={hookOf(chat)} actions={chat.actions} renderSlot={stubRenderSlot} renderSlotChain={fallbackRenderSlotChain} SessionProvider={SessionProviderStub} views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }} send={vi.fn()} stop={vi.fn()} open={open} />, ) return { view, open, chat } } it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => { const { view, open } = rootProps({ rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }], }) expect(view.getByText('Workspace')).toBeTruthy() expect(view.getByText('/')).toBeTruthy() fireEvent.click(view.getByText('Workspace')) expect(open).toHaveBeenCalledWith('root-1' as SessionId) // The last crumb is the current session: disabled, no navigation. fireEvent.click(view.getByText('Current')) expect(open).toHaveBeenCalledTimes(1) }) it('a broken parent link stops the ancestry walk at the known chain', () => { const { view } = rootProps({ rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }], }) // The walk keeps s1 itself and stops where the parent is unknown. expect(view.getByText('Orphan')).toBeTruthy() }) it('falls back to the raw session id without ancestry and counts user turns', () => { const { view } = rootProps({ snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] }, }) expect(view.getByText(SID)).toBeTruthy() expect(view.getByText(/1 turns/)).toBeTruthy() }) it('surfaces promptError through the composer error strip', () => { const { view } = rootProps({ snapshot: { promptError: { op: 'stop', error: { message: 'halt', code: 'internal' } } as never }, }) expect(view.getByText(/停止失败:halt(internal)/)).toBeTruthy() }) it('an unknown stored view id falls back to the first registered view', () => { const { chat } = rootProps({}) cleanup() chat.actions.setView('gone') const view = render( } useSessions={listHook([])} useStore={hookOf(chat)} actions={chat.actions} renderSlot={stubRenderSlot} renderSlotChain={fallbackRenderSlotChain} SessionProvider={SessionProviderStub} views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }} send={vi.fn()} stop={vi.fn()} open={vi.fn()} />, ) expect(view.getByTestId('view-body')).toBeTruthy() }) }) describe('DetailsPanel branches', () => { function panel(selection: SelectionTarget | null, snapshot?: Partial) { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) return render( } useSessions={listHook([])} useStore={hookOf(chat)} actions={chat.actions} closeDetails={vi.fn()} />, ) } it('shows non-JSON args verbatim (streaming fragment path)', () => { const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, { runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }], }) expect(view.getByText('{"cmd": tru')).toBeTruthy() }) it('a selection without callId renders the empty hint (selector null arm)', () => { const view = panel({ turnSeq: 2 }) expect(view.getByText(/点击消息流中的工具行查看详情/)).toBeTruthy() }) it('snapshot updates re-run the material selector through the shallow equality arm', () => { let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot const subs = new Set<() => void>() const source = { getSnapshot: () => snap, subscribe: (fn: () => void) => { subs.add(fn) return () => subs.delete(fn) }, } const chat = createChatStore().create() chat.actions.select({ turnSeq: 1, callId: 'c9' }) const view = render( } useSessions={listHook([])} useStore={hookOf(chat)} actions={chat.actions} closeDetails={vi.fn()} />, ) expect(view.getByText(/"a": 1/)).toBeTruthy() // Top-level swap with identical material members: the eq arm short-circuits. snap = { ...snap } for (const fn of [...subs]) fn() expect(view.getByText(/"a": 1/)).toBeTruthy() }) it('windowless call material: no name/args fallback to callId, mixed node walk skips non-matches', () => { // A tool-result whose call head fell outside the window (call === null), // preceded by non-matching nodes so the walk exercises both filter arms. const view = panel({ turnSeq: 1, callId: 'c8' }, { nodes: [ { kind: 'user', seq: 1, content: [], source: null } as never, { kind: 'tool-result', seq: 2, callId: 'other', call: { name: 'x', argsRaw: '{}' }, content: [], isError: false, callView: null, resultView: null } as never, { kind: 'tool-result', seq: 3, callId: 'c8', call: null, content: [], isError: false, callView: null, resultView: null } as never, ], }) expect(view.getByText('c8')).toBeTruthy() }) it('stringifies non-text result blocks and renders error-only results', () => { const withBlocks = panel({ turnSeq: 1, callId: 'c2' }, { nodes: [{ kind: 'tool-result', seq: 3, callId: 'c2', call: { name: 'read', argsRaw: '{}' }, content: [{ type: 'image', data: 'x' } as never], isError: false, callView: null, resultView: null, } as never], }) expect(withBlocks.getByText(/"type": "image"/)).toBeTruthy() const errorOnly = panel({ turnSeq: 1, callId: 'c3' }, { nodes: [{ kind: 'tool-result', seq: 4, callId: 'c3', call: { name: 'bash', argsRaw: '{}' }, content: [], isError: true, error: { name: 'ToolError', code: 'timeout' }, callView: null, resultView: null, } as never], }) expect(errorOnly.getByText(/ToolError: timeout/)).toBeTruthy() }) }) describe('EmptyState branches', () => { const noopCreate = () => Promise.resolve() it('keeps the draft and surfaces a local error strip when startSession rejects', async () => { const startSession = vi.fn(() => Promise.reject(new Error('create down'))) const view = render( , ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'first task' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => expect(view.getByText(/发送失败:create down/)).toBeTruthy()) expect((textarea as HTMLTextAreaElement).value).toBe('first task') }) it('non-Error rejection reasons stringify into the error strip', async () => { const startSession = vi.fn(() => Promise.reject('plain-string')) const view = render( , ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'go' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy()) }) it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => { const startSession = vi.fn(() => Promise.resolve()) const view = render( , ) fireEvent.click(view.getByRole('button', { name: '项目目录' })) expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) .toEqual(['proj', 'New Workspace']) fireEvent.click(view.getByRole('menuitem', { name: 'proj' })) expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj') fireEvent.click(view.getByRole('button', { name: '项目目录' })) fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' })) const custom = view.getByLabelText('Folder path') fireEvent.change(custom, { target: { value: '/typed/dir' } }) fireEvent.click(view.getByRole('button', { name: 'Open Folder' })) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'task' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' })) }) it('Create modal surfaces inject failures inline', async () => { const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked'))) const view = render( Promise.resolve()} createWorkspaceSession={createWorkspaceSession} />, ) fireEvent.click(view.getByRole('button', { name: '项目目录' })) fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) fireEvent.click(view.getByRole('menuitem', { name: 'Create new' })) fireEvent.click(view.getByRole('button', { name: 'Create' })) await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked')) }) })