Files
deepseek-harness/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
imccyu 8b3d1ac943 refactor(gui): move the snapshot-store engine into the client runtime
The data layer no longer depends on the React glue package, and business
plugins no longer depend on web-react at all:

- The store engine (zustand vanilla + immer + persist + dev freeze),
  defineStore, and shallowEqual move to @deepseek-ai/dsh-client-runtime,
  exported from the ./client main entry — no ./store subpath survives on
  either package (the web-react one is deleted, none is opened on runtime).
- Store products are bare snapshot sources: useSelector leaves
  SnapshotStore/StoreInstance and Session; every hook is composed at the
  binding site in web-react's renderer (per-source cached uSES binding).
  The SlotRendererHost sessions face carries bare observables only.
- SessionProvider becomes a standard-kit seat: an entry whose children
  declare a session-scope slot receives the framework component as a prop,
  retiring the last value import of web-react from plugin packages.
  UseSession and the session-area types now live in ui-slots.
- web-react shrinks to the shell-only React glue (renderer, providers,
  uSES bridge); zustand/immer belong to runtime alone; the module-table
  seed and tsdown externals drop the web-react/store seat.
- NODE_ENV replacement is defined once in the shared tsdown client preset
  (browser bundles inline the engine and lost vite's define); the 3-line
  process.env typecheck shim moves to runtime with the engine.
- Stray tsc artifacts (.js/.d.ts/.d.ts.map beside sources under src/)
  swept repo-wide; they shadow real sources under vitest resolution.

Verified: both aggregate typecheck programs at zero; 604 client tests
green; repo-wide grep for web-react/store at zero; real-host playwright
run 7/7 including persist round-trip.

ci: fix test/docs
2026-07-23 03:30:06 +08:00

156 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// ToolViewOutlet inject cache + crash fallback + retry, StatsLine no-cache
// join, PendingCard reason strip, AssistantMarkdown single-line reasoning,
// ChatView view-body fallbacks, and apply's action lambdas.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { act } from '@testing-library/react'
import type { SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
call: { name: 'bash', argsRaw: '{"command":"x"}' },
content: [], isError: false, callView: null, resultView: null,
})
const viewProps = (): ToolViewProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
})
describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
const view = render(
<MessageItem node={{
kind: 'steering', seq: 2, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
})
it('context and unknown nodes render their JSON rows', () => {
const ctxView = render(
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null, meta: { k: 1 } } as never} />,
)
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
const unknownView = render(
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
)
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
})
})
describe('small branch tails', () => {
it('PendingCard approval reason renders when present', () => {
const view = render(
<PendingCard item={{ kind: 'approval', rpcId: 'r1' as RpcId, approvalId: 'a1', toolName: 'rm', reason: 'careful' }} />,
)
expect(view.getByText('careful')).toBeTruthy()
})
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
const view = render(
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
)
expect(view.getByText('one-liner')).toBeTruthy()
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
// cacheHitPct is null only when input+cacheRead are both zero (pure
// output accounting) — any input makes it a real 0%.
const snap = {
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession} />,
)
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
})
})
describe('ToolViewOutlet dispatch', () => {
it('caches the inject factory per (registration x session) and merges its props', () => {
const registry = new ToolViewRegistry()
const inject = vi.fn((sessionId: SessionId) => ({ extra: `injected:${sessionId}` }))
registry.register('bash',
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
{ inject })
// Pure props machinery: the outlet feeds its own sessionId to the
// factory — no provider/context needed (terminal channel form).
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// Remount under the SAME session: cache hit, factory not re-run.
view.unmount()
const second = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(second.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// A different session is a distinct cache key: factory runs once more.
second.unmount()
const other = render(
<ToolViewOutlet registry={registry} sessionId={'s2' as SessionId} toolName="bash" viewProps={viewProps()} />,
)
expect(other.getByTestId('row').textContent).toBe('injected:s2')
expect(inject).toHaveBeenCalledTimes(2)
})
it('a crashing custom row falls back to GenericToolCard and retries on re-registration', () => {
const registry = new ToolViewRegistry()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
// React dev builds re-dispatch boundary-caught errors as window 'error'
// events (invokeGuardedCallback); swallow them so vitest sees the caught path.
const swallow = (e: Event): void => { e.preventDefault() }
window.addEventListener('error', swallow)
try {
const Bomb = () => { throw new Error('row bomb') }
registry.register('bash', Bomb as never)
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
// Crash caught: generic row rendered instead.
expect(view.getByText('Bash')).toBeTruthy()
// A new registration bumps the version; the boundary retries the custom row.
act(() => { registry.register('bash', (() => <div data-testid="fixed" />) as never) })
expect(view.getByTestId('fixed')).toBeTruthy()
} finally {
window.removeEventListener('error', swallow)
consoleError.mockRestore()
}
})
it('registry miss renders the generic row directly', () => {
const registry = new ToolViewRegistry()
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByText('Bash')).toBeTruthy()
})
})