Merge PR #500 into CI optimization
This commit is contained in:
276
packages/client/ui-conversation/tests/apply-inject.spec.tsx
Normal file
276
packages/client/ui-conversation/tests/apply-inject.spec.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply inject factories exercised end to end: the conversation slot surface
|
||||
// (ancestry feed, views triple, active view, composer choreography incl.
|
||||
// optimistic clear + failure restore, renderView chrome assembly, watch-driven
|
||||
// open), the details surface, and the empty-state surface (cwd derivation
|
||||
// cache). Complements chat-apply.spec.tsx, which stops at registration.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConversationService, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver) {
|
||||
recorded.push(prop)
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(spy as Context)
|
||||
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
||||
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
||||
return symbol
|
||||
})()
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: ROOT, 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
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
})
|
||||
const snap = snapshotBase()
|
||||
const sessionFake = {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: () => () => {},
|
||||
useSelector: undefined as unknown,
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn(() => Promise.resolve()),
|
||||
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
}
|
||||
sessionFake.useSelector = bindSnapshotSelector(sessionFake as never)
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
if (scoped === undefined) {
|
||||
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context
|
||||
scopes.set(id, scoped)
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
manager: { get: () => sessionFake },
|
||||
ancestry: (id: SessionId) => {
|
||||
const s = listStore.getSnapshot().byId[id]
|
||||
return s === undefined ? [] : [s]
|
||||
},
|
||||
scope: (id: SessionId) => mint(id),
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
const layoutFake = {
|
||||
current: createSnapshotStore<{ sessionId?: SessionId; viewFor: Record<string, string> }>({ viewFor: {} }),
|
||||
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
|
||||
}
|
||||
ctx.provide('layout', layoutFake)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.define('conversation', { kind: 'single', scope: 'session' })
|
||||
slots.define('details', { kind: 'single', scope: 'session' })
|
||||
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
|
||||
const binding: SessionBinding = {
|
||||
sessionId: ROOT as never,
|
||||
session: { useSelector: sessionFake.useSelector } as never,
|
||||
ctx: mint(ROOT) as never,
|
||||
}
|
||||
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => {
|
||||
const entries = slots.entries(key)
|
||||
return entries[0]! as { options: { inject: (b: unknown) => Record<string, unknown> } }
|
||||
}
|
||||
return { ctx, slots, binding, sessionFake, sessionsFake, layoutFake, mint, entryOf }
|
||||
}
|
||||
|
||||
describe('conversation slot inject surface', () => {
|
||||
it('assembles the full surface and pulls history through the watch signal', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
useAncestry: () => readonly { id: SessionId }[]
|
||||
views: { list(): readonly ViewEntry[]; version(): number; subscribe(fn: () => void): () => void }
|
||||
useActiveView: () => string | undefined
|
||||
composer: { useDraft: () => string; setDraft(t: string): void; send(m: string): void; stop(): void }
|
||||
actions: { openView(v: string): void; open(id: SessionId): void }
|
||||
renderView: (entry: ViewEntry) => unknown
|
||||
}
|
||||
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
|
||||
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
|
||||
injected.actions.openView('chat')
|
||||
expect(b.layoutFake.openView).toHaveBeenCalledWith(ROOT, 'chat')
|
||||
injected.actions.open(ROOT)
|
||||
expect(b.layoutFake.open).toHaveBeenCalledWith(ROOT)
|
||||
})
|
||||
|
||||
it('composer send trims, optimistically clears, and restores on failure; stop swallows rejection', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
composer: { setDraft(t: string): void; send(m: 'queue'): void; stop(): void }
|
||||
}
|
||||
const scoped = b.mint(ROOT).get('conversation') as ConversationService
|
||||
// Whitespace-only draft: no send.
|
||||
scoped.drafts.set(' ')
|
||||
injected.composer.send('queue')
|
||||
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
||||
// Success: cleared and stays cleared.
|
||||
injected.composer.setDraft('hello')
|
||||
injected.composer.send('queue')
|
||||
expect(scoped.drafts.getSnapshot()).toBe('')
|
||||
await Promise.resolve()
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
|
||||
// Failure: restored (draft still empty when the rejection lands).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
injected.composer.setDraft('retry me')
|
||||
injected.composer.send('queue')
|
||||
await vi.waitFor(() => {
|
||||
expect(scoped.drafts.getSnapshot()).toBe('retry me')
|
||||
})
|
||||
// Failure with new typing: no clobber.
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
injected.composer.send('queue')
|
||||
injected.composer.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(scoped.drafts.getSnapshot()).toBe('typed during flight')
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
|
||||
injected.composer.stop()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
})
|
||||
|
||||
it('view actions forward: openDetails writes selection through the scoped service, loadOlder hits the session', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
// viewProps rides renderView's closure; reach the actions through a rendered entry.
|
||||
renderView: (entry: ViewEntry) => React.ReactNode
|
||||
}
|
||||
let captured: { openDetails(t: { turnSeq: number; callId?: string }): void; loadOlder(): void } | undefined
|
||||
const Probe = (p: { actions: typeof captured }) => {
|
||||
captured = p.actions
|
||||
return null
|
||||
}
|
||||
render(createElement('div', null, injected.renderView({
|
||||
id: 'chat', label: 'Chat', component: Probe,
|
||||
} as unknown as ViewEntry)))
|
||||
captured!.openDetails({ turnSeq: 2, callId: 'c1' })
|
||||
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
|
||||
const scoped = b.mint(ROOT).get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
|
||||
expect(scoped.selection.getSnapshot()).toEqual({ turnSeq: 2, callId: 'c1' })
|
||||
captured!.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renderView mounts chrome header/footer around the view body', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
renderView: (entry: ViewEntry) => React.ReactNode
|
||||
}
|
||||
const entry = {
|
||||
id: 'chat', label: 'Chat',
|
||||
component: () => createElement('div', { 'data-testid': 'body' }),
|
||||
chrome: {
|
||||
header: () => createElement('div', { 'data-testid': 'hd' }),
|
||||
footer: () => createElement('div', { 'data-testid': 'ft' }),
|
||||
},
|
||||
} as unknown as ViewEntry
|
||||
const view = render(createElement('div', null, injected.renderView(entry)))
|
||||
expect(view.getByTestId('hd')).toBeTruthy()
|
||||
expect(view.getByTestId('body')).toBeTruthy()
|
||||
expect(view.getByTestId('ft')).toBeTruthy()
|
||||
// Ancestry and draft/active-view hooks execute inside a component tree.
|
||||
const HookProbe = () => {
|
||||
const injected2 = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
useAncestry: () => readonly { title: string }[]
|
||||
useActiveView: () => string | undefined
|
||||
composer: { useDraft: () => string }
|
||||
}
|
||||
const chain = injected2.useAncestry()
|
||||
const active = injected2.useActiveView()
|
||||
const draft = injected2.composer.useDraft()
|
||||
return createElement('i', { 'data-testid': 'probe' }, `${chain.length}|${active ?? 'none'}|${draft}`)
|
||||
}
|
||||
const probe = render(createElement(HookProbe))
|
||||
// Draft content carries over from the composer case (per-scope store is
|
||||
// process-resident); the probe asserts hook wiring, not draft value.
|
||||
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
|
||||
// A list-store update while mounted drives the ancestry selector's
|
||||
// shallowEqual arm (same derived chain → short-circuit, no re-render churn).
|
||||
await act(async () => {
|
||||
b.sessionsFake.list.update((d: { byId: Record<string, { updatedAt: number }> }) => {
|
||||
d.byId[ROOT]!.updatedAt = 2
|
||||
})
|
||||
})
|
||||
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
|
||||
// The views read-face triple forwards to the service registry.
|
||||
const injected3 = b.entryOf('conversation').options.inject(b.binding) as {
|
||||
views: { list(): readonly { id: string }[]; subscribe(fn: () => void): () => void; version(): number }
|
||||
}
|
||||
expect(injected3.views.list().map(v => v.id)).toEqual(['chat'])
|
||||
const beforeVersion = injected3.views.version()
|
||||
const listener = vi.fn()
|
||||
const unsub = injected3.views.subscribe(listener)
|
||||
const conversation = b.ctx.get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
|
||||
const offExtra = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
|
||||
expect(listener).toHaveBeenCalled()
|
||||
expect(injected3.views.version()).toBeGreaterThan(beforeVersion)
|
||||
offExtra()
|
||||
unsub()
|
||||
})
|
||||
})
|
||||
|
||||
describe('details and empty inject surfaces', () => {
|
||||
it('details surface wires selection and closeDetails', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('details').options.inject(b.binding) as {
|
||||
useSelection: unknown
|
||||
actions: { closeDetails(): void }
|
||||
}
|
||||
expect(injected.useSelection).toBeTypeOf('function')
|
||||
injected.actions.closeDetails()
|
||||
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('empty surface derives the deduped cwd set with a per-state cache and starts sessions', async () => {
|
||||
const b = await bench()
|
||||
const injected = b.entryOf('conversation.empty').options.inject({ ctx: b.ctx }) as {
|
||||
useCwds: (sel: (s: readonly string[]) => unknown, eq?: unknown) => unknown
|
||||
actions: { startSession(opts: { text: string; mode: 'queue' }): Promise<void> }
|
||||
}
|
||||
const CwdsProbe = () => {
|
||||
const cwds = injected.useCwds(s => s) as readonly string[]
|
||||
return createElement('i', { 'data-testid': 'cwds' }, cwds.join(','))
|
||||
}
|
||||
const view = render(createElement(CwdsProbe))
|
||||
expect(view.getByTestId('cwds').textContent).toBe('/proj')
|
||||
await injected.actions.startSession({ text: 'go', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
105
packages/client/ui-conversation/tests/chat-apply.spec.tsx
Normal file
105
packages/client/ui-conversation/tests/chat-apply.spec.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply wiring: services provided, chat view + footer chrome registered, the
|
||||
// three slot registrations land against ui-layout-shaped specs, and the bash
|
||||
// samples resolve differentially (sub-session default scope). Full-chain
|
||||
// rendering belongs to the shell e2e; this spec stops at the assembly surface.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls ui-layout's SlotMap declaration merge into this spec's
|
||||
// program so the slot keys below typecheck in the client lane.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
const CHILD = 'child-1' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'R', running: false, updatedAt: 1 },
|
||||
[CHILD]: { id: CHILD, title: 'C', parentId: ROOT, running: false, updatedAt: 2 },
|
||||
},
|
||||
})
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
manager: { get: vi.fn() },
|
||||
ancestry: () => [],
|
||||
scope: () => undefined,
|
||||
create: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
ctx.provide('layout', {
|
||||
current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }),
|
||||
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
|
||||
})
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
// Specs owned by ui-layout in production; declared here so registrations land.
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.define('conversation', { kind: 'single', scope: 'session' })
|
||||
slots.define('details', { kind: 'single', scope: 'session' })
|
||||
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
return { ctx, fiber, slots }
|
||||
}
|
||||
|
||||
describe('apply wiring', () => {
|
||||
it('provides conversation and toolviews services', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
expect(b.ctx.get('conversation')).toBeDefined()
|
||||
expect(b.ctx.get('toolviews')).toBeInstanceOf(ToolViewRegistry)
|
||||
})
|
||||
|
||||
it('registers the chat view with the stats footer', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const conversation = b.ctx.get('conversation') as ConversationService
|
||||
const views = conversation.views()
|
||||
expect(views.map((v) => v.id)).toEqual(['chat'])
|
||||
expect(views[0]?.chrome?.footer).toBeDefined()
|
||||
})
|
||||
|
||||
it('occupies conversation/details/conversation.empty with inject factories', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
for (const key of ['conversation', 'details', 'conversation.empty'] as const) {
|
||||
const entries = b.slots.entries(key)
|
||||
expect(entries, key).toHaveLength(1)
|
||||
expect((entries[0]!.options as { inject?: unknown }).inject, key).toBeTypeOf('function')
|
||||
}
|
||||
})
|
||||
|
||||
it('bash samples resolve differentially: scoped row for sub-sessions, global for roots', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const toolviews = b.ctx.get('toolviews') as ToolViewRegistry
|
||||
const forChild = toolviews.resolve('bash', CHILD)
|
||||
const forRoot = toolviews.resolve('bash', ROOT)
|
||||
expect(forChild).toBeDefined()
|
||||
expect(forRoot).toBeDefined()
|
||||
expect(forChild!.component).not.toBe(forRoot!.component)
|
||||
})
|
||||
|
||||
it('plugin fiber disposal collects every registration (unload cascade)', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
await b.fiber.dispose()
|
||||
expect(b.slots.entries('conversation')).toHaveLength(0)
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
|
||||
expect(b.ctx.get('conversation')).toBeUndefined()
|
||||
expect(b.ctx.get('toolviews')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
156
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
Normal file
156
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// @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 { bindSnapshotSelector, createSessionProvider } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionBinding as ReactSessionBinding, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
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={bindSnapshotSelector(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 binding) and merges its props', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const inject = vi.fn(() => ({ extra: 'injected' }))
|
||||
registry.register('bash',
|
||||
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
|
||||
{ inject })
|
||||
// InjectedRow reads the session binding from context: mount through the
|
||||
// real SessionProvider so the (factory x binding) cache path executes.
|
||||
const binding: ReactSessionBinding = {
|
||||
sessionId: SID,
|
||||
session: { useSelector: (() => { throw new Error('unused') }) as never },
|
||||
ctx: {},
|
||||
}
|
||||
const Provider = createSessionProvider({
|
||||
useCurrent: () => SID,
|
||||
resolveBinding: () => binding,
|
||||
renderBody: () => (
|
||||
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />
|
||||
),
|
||||
})
|
||||
const view = render(<Provider />)
|
||||
expect(view.getByTestId('row').textContent).toBe('injected')
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
// Remount against the SAME binding: cache hit, factory not re-run.
|
||||
view.unmount()
|
||||
const second = render(<Provider />)
|
||||
expect(second.getByTestId('row').textContent).toBe('injected')
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// @vitest-environment jsdom
|
||||
// StatsLine (chrome.footer first consumer): totals derivation + the RFC hard
|
||||
// acceptance — zero renders during streaming. Bash sample: differential
|
||||
// registry hits per session, teardown reverts to the generic row.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ChromeProps, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { StatsLine, deriveStats } from '../src/client/chat/StatsLine.tsx'
|
||||
import { BashRow, ScopedBashRow, registerBashSamples } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
|
||||
import { childSessionScope } from '../src/client/chat/register.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set(next: Partial<ConversationSnapshot>) {
|
||||
snap = { ...snap, ...next }
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: (fn: () => void) => {
|
||||
subs.add(fn)
|
||||
return () => subs.delete(fn)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('deriveStats', () => {
|
||||
it('folds turns/steps/tokens and cache hit percentage', () => {
|
||||
const stats = deriveStats([
|
||||
assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
|
||||
assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
|
||||
assistant(3, 2),
|
||||
])
|
||||
expect(stats.turns).toBe(2)
|
||||
expect(stats.steps).toBe(3)
|
||||
expect(stats.tokens).toBe(1200)
|
||||
expect(stats.cacheHitPct).toBe(82)
|
||||
})
|
||||
|
||||
it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [],
|
||||
isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const stats = deriveStats([tool, assistant(1, 1)])
|
||||
expect(stats.steps).toBe(1)
|
||||
expect(stats.cacheHitPct).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatsLine', () => {
|
||||
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
|
||||
return { sessionId: SID, useSession: bindSnapshotSelector(source) as unknown as UseSession }
|
||||
}
|
||||
|
||||
it('renders the joined stats row and hides with zero steps', () => {
|
||||
const { source } = makeSource({
|
||||
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
|
||||
})
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy()
|
||||
const empty = makeSource()
|
||||
const emptyView = render(<StatsLine {...props(empty.source)} />)
|
||||
expect(emptyView.container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
|
||||
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
let renders = 0
|
||||
function Counting(p: ChromeProps) {
|
||||
renders += 1
|
||||
return <StatsLine {...p} />
|
||||
}
|
||||
render(<Counting {...props(source)} />)
|
||||
const before = renders
|
||||
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
|
||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }))
|
||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }))
|
||||
act(() => set({ running: true }))
|
||||
expect(renders).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bash toolview samples', () => {
|
||||
const result = (callId: string): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 3, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
const viewProps = (openDetails = vi.fn()): ToolViewProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
|
||||
actions: { openDetails },
|
||||
t: (k) => k,
|
||||
})
|
||||
|
||||
function outlet(registry: ToolViewRegistry, sessionId: SessionId, p = viewProps()) {
|
||||
return render(
|
||||
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName="bash" viewProps={p} />,
|
||||
)
|
||||
}
|
||||
|
||||
it('differential rendering: scoped row for the matching session, global elsewhere', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
registerBashSamples(registry, (id) => id === ('swarm' as SessionId))
|
||||
const scoped = outlet(registry, 'swarm' as SessionId)
|
||||
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
const plain = outlet(registry, SID)
|
||||
expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('teardown removes both registrations and falls back to the generic row', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const off = registerBashSamples(registry, () => true)
|
||||
const view = outlet(registry, SID)
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
act(() => off())
|
||||
expect(view.container.querySelector('[data-sample]')).toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('childSessionScope matches sub-sessions via the injected list read face', () => {
|
||||
const child = 'child' as SessionId
|
||||
const root = 'root' as SessionId
|
||||
const scope = childSessionScope({
|
||||
getSnapshot: () => ({
|
||||
ids: [root, child],
|
||||
byId: {
|
||||
[root]: { id: root, title: 'r', running: false, updatedAt: 0 },
|
||||
[child]: { id: child, title: 'c', parentId: root, running: false, updatedAt: 0 },
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(scope(child)).toBe(true)
|
||||
expect(scope(root)).toBe(false)
|
||||
expect(scope('gone' as SessionId)).toBe(false)
|
||||
})
|
||||
|
||||
it('sample rows summarize the command and hand clicks to openDetails', () => {
|
||||
const open = vi.fn()
|
||||
const p = viewProps(open)
|
||||
const global = render(<BashRow {...p} />)
|
||||
expect(global.getByText('Build')).toBeTruthy()
|
||||
fireEvent.click(global.getByText('Build'))
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
const scoped = render(<ScopedBashRow {...p} />)
|
||||
expect(scoped.getByText('scoped')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
147
packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
Normal file
147
packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
|
||||
turn: 1, step: 1, callView: null, ...over,
|
||||
})
|
||||
|
||||
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
describe('tool-call-model', () => {
|
||||
it('classifies known tools and falls back to others', () => {
|
||||
expect(classifyTool('bash')).toBe('bash')
|
||||
expect(classifyTool('read')).toBe('read')
|
||||
expect(classifyTool('web_fetch')).toBe('read')
|
||||
expect(classifyTool('web_search')).toBe('search')
|
||||
expect(classifyTool('grep')).toBe('search')
|
||||
expect(classifyTool('todo_write')).toBe('others')
|
||||
})
|
||||
|
||||
it('derives state across running/ok/error/interrupted', () => {
|
||||
expect(toolRowModel('bash', running()).state).toBe('running')
|
||||
expect(toolRowModel('bash', result()).state).toBe('ok')
|
||||
expect(toolRowModel('bash', result({ isError: true })).state).toBe('error')
|
||||
expect(toolRowModel('bash', result({ isError: true, error: { name: 'E', code: 'interrupted' } })).state).toBe('stopped')
|
||||
})
|
||||
|
||||
it('derives the bash summary from description over command', () => {
|
||||
const m = toolRowModel('bash', running())
|
||||
expect(m.title).toBe('Bash')
|
||||
expect(m.summary).toBe('List files')
|
||||
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd')
|
||||
})
|
||||
|
||||
it('keeps summaries single-line and falls back for opaque args', () => {
|
||||
expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
|
||||
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
|
||||
// Others rows prefix the real tool name into the summary slot (figma-flows
|
||||
// ruling: static "Tool call" title, name rides the mutable summary).
|
||||
expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
|
||||
expect(toolRowModel('x', running({ argsRaw: 'not json' })).summary).toBe('x · not json')
|
||||
expect(toolRowModel('x', running({ argsRaw: '' })).summary).toBe('x · c1')
|
||||
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
|
||||
})
|
||||
|
||||
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
|
||||
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
|
||||
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
|
||||
expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolRow', () => {
|
||||
const rowProps = {
|
||||
variant: 'bash' as const, icon: <i data-testid="tool-icon" />, title: 'Bash',
|
||||
summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const,
|
||||
}
|
||||
|
||||
it('renders leading icon, title and summary while collapsed', () => {
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.queryByTestId('tool-icon')).toBeNull()
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('running and error states replace the icon with a StateDot', () => {
|
||||
const runningView = render(<ToolRow {...rowProps} state="running" />)
|
||||
expect(runningView.queryByTestId('tool-icon')).toBeNull()
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
const errorView = render(<ToolRow {...rowProps} state="error" />)
|
||||
expect(errorView.queryByTestId('tool-icon')).toBeNull()
|
||||
})
|
||||
|
||||
it('non-expandable rows render a passive leading slot', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} />)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GenericToolCard', () => {
|
||||
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolViewProps => ({
|
||||
callId: 'c1', toolName, block,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
|
||||
actions: { openDetails: vi.fn() },
|
||||
t: (k) => k,
|
||||
})
|
||||
|
||||
it('renders the classified variant row from the frozen slice', () => {
|
||||
const view = render(<GenericToolCard {...props('bash', result())} />)
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('unknown tools land on the others variant titled Tool call', () => {
|
||||
const view = render(
|
||||
<GenericToolCard {...props('todo_write', running({ name: 'todo_write', argsRaw: '{"note":"x"}' }))} />,
|
||||
)
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click reaches actions.openDetails', () => {
|
||||
const p = props('bash', result())
|
||||
const view = render(<GenericToolCard {...p} />)
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(p.actions.openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
305
packages/client/ui-conversation/tests/chat-view.spec.tsx
Normal file
305
packages/client/ui-conversation/tests/chat-view.spec.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
// @vitest-environment jsdom
|
||||
// ChatView behavior: flow derivation, streaming isolation (Profiler counts),
|
||||
// toolview dispatch and selection handoff — driven through a scripted
|
||||
// ObservableSnapshot fake, no wire.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/** Scripted snapshot source: set() swaps the top-level object like the real Session. */
|
||||
function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set(next: Partial<ConversationSnapshot>) {
|
||||
snap = { ...snap, ...next }
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: (fn: () => void) => {
|
||||
subs.add(fn)
|
||||
return () => subs.delete(fn)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const user = (seq: number, text: string): UserMessageNode => ({
|
||||
kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null,
|
||||
})
|
||||
const assistant = (seq: number, text: string): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
|
||||
})
|
||||
|
||||
function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const { set, source } = makeSource(init)
|
||||
const registry = new ToolViewRegistry()
|
||||
const ChatView = createChatView({ toolviews: registry, t: (k) => k })
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
const selection = makeSelection()
|
||||
const props: ConvViewProps = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(source) as unknown as UseSession,
|
||||
useSelection: bindSnapshotSelector(selection.source),
|
||||
actions: { openDetails, loadOlder },
|
||||
slots: { renderSlot: () => null } as never,
|
||||
}
|
||||
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection: selection.set }
|
||||
}
|
||||
|
||||
function makeSelection() {
|
||||
let sel: SelectionTarget | null = null
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set(next: SelectionTarget | null) {
|
||||
sel = next
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
getSnapshot: () => sel,
|
||||
subscribe: (fn: () => void) => {
|
||||
subs.add(fn)
|
||||
return () => subs.delete(fn)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
it('groups consecutive tool results and keeps stable keys', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'hi'), assistant(2, 'let me look'), toolResult(3, 'a'), toolResult(4, 'b'),
|
||||
assistant(5, 'found'), toolResult(6, 'c'),
|
||||
]
|
||||
const items = deriveChatFlow(nodes)
|
||||
expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
|
||||
const group = items[2]!
|
||||
expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b'])
|
||||
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
|
||||
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
it('a windowless tool result (call head truncated) renders with an empty tool name', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [{ ...toolResult(3, 'w1'), call: null }],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// classifyTool('') → others; the summary slot falls back to the callId.
|
||||
expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
|
||||
expect(view.getByText('w1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('prepend keeps the viewport anchored when the reader is NOT at the bottom (no lastKey force)', () => {
|
||||
// Covers the prepend early-return arm where lastItem exists but the key
|
||||
// path is not taken (anchor branch wins before the appended-user check).
|
||||
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
scroller.scrollTop = 50
|
||||
fireEvent.scroll(scroller)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||
act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }))
|
||||
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
|
||||
})
|
||||
|
||||
it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText('do the thing')).toBeTruthy()
|
||||
expect(view.getByText('running tools')).toBeTruthy()
|
||||
expect(view.getAllByText('Bash')).toHaveLength(2)
|
||||
expect(view.getByText('run a')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('streaming partial frames re-render only the tail (Profiler count)', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
|
||||
})
|
||||
let renders = 0
|
||||
const counting = (
|
||||
<Profiler id="chat" onRender={() => { renders += 1 }}>
|
||||
<h.ChatView {...h.props} />
|
||||
</Profiler>
|
||||
)
|
||||
const view = render(counting)
|
||||
const before = renders
|
||||
const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
|
||||
})
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
|
||||
})
|
||||
expect(view.getByText('streaming… more')).toBeTruthy()
|
||||
// Each chunk commits exactly one profiler pass (the tail), never a full-tree storm.
|
||||
expect(renders - before).toBe(2)
|
||||
expect(view.container.querySelector('[class*="toolGroup"]')!.innerHTML).toBe(beforeHtml)
|
||||
})
|
||||
|
||||
it('streaming leaves neighbor tool rows and history items at zero re-renders', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
|
||||
})
|
||||
let rowRenders = 0
|
||||
h.registry.register('bash', () => {
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('counting-row')).toBeTruthy()
|
||||
const afterMount = rowRenders
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } })
|
||||
})
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } })
|
||||
})
|
||||
expect(rowRenders).toBe(afterMount)
|
||||
})
|
||||
|
||||
it('tool row expands to the args body via the leading slot toggle', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a tool row opens details with callId and toolName; selection paints the outline', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
|
||||
expect(view.container.querySelector('[data-selected]')).toBeNull()
|
||||
act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }))
|
||||
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('running calls render as a live tool group with the running state', () => {
|
||||
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(view.getByText('cmd-r1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a scoped toolview registration takes over rendering for its session only', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
h.registry.register('bash', () => <div data-testid="custom-bash" />, { scope: (id) => id === SID })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('custom-bash')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('unregistering a toolview falls back to the generic row live', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const off = h.registry.register('bash', () => <div data-testid="custom-bash" />)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('custom-bash')).toBeTruthy()
|
||||
act(() => off())
|
||||
expect(view.queryByTestId('custom-bash')).toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
// jsdom has no layout: fake the metrics the anchor math reads.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
|
||||
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
||||
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }))
|
||||
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
|
||||
// A new trailing user bubble (own words) force-scrolls to the bottom.
|
||||
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }))
|
||||
expect(scroller.scrollTop).toBe(1600)
|
||||
})
|
||||
|
||||
it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
scroller.scrollTop = 100 // far from bottom
|
||||
fireEvent.scroll(scroller)
|
||||
const backButton = view.getByLabelText('回到底部')
|
||||
expect(backButton).toBeTruthy()
|
||||
// Streaming growth must NOT drag a scrolled-away reader down.
|
||||
act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }))
|
||||
expect(scroller.scrollTop).toBe(100)
|
||||
fireEvent.click(backButton)
|
||||
expect(scroller.scrollTop).toBe(1000)
|
||||
// At the bottom again: follow re-arms and the button unmounts.
|
||||
expect(view.queryByLabelText('回到底部')).toBeNull()
|
||||
})
|
||||
|
||||
it('paging button loads older and shows its busy label', () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
expect(h.loadOlder).toHaveBeenCalledTimes(1)
|
||||
act(() => h.set({ loadingOlder: true }))
|
||||
expect(view.getByText('加载中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows open error and loading states', () => {
|
||||
const h = makeHarness({
|
||||
openState: 'error',
|
||||
openError: { code: 'internal', message: 'boom' } as never,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText(/历史加载失败:boom/)).toBeTruthy()
|
||||
const loading = makeHarness({ openState: 'loading' })
|
||||
const lv = render(<loading.ChatView {...loading.props} />)
|
||||
expect(lv.getByText('载入历史…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pending interactions render placeholder cards', () => {
|
||||
const h = makeHarness({
|
||||
pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText(/等待审批/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
127
packages/client/ui-conversation/tests/coverage-tails.spec.tsx
Normal file
127
packages/client/ui-conversation/tests/coverage-tails.spec.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||
// PendingCard question arm, bash sample error pill, registry disposer
|
||||
// idempotence re-entry, register.ts explicit bashSampleScope override, the
|
||||
// node-half empty apply, and AssistantMarkdown reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { registerChat } from '../src/client/chat/register.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('tails', () => {
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
expect(nodeApply()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||
const view = render(
|
||||
<ToolRow variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
|
||||
)
|
||||
expect(view.queryByTestId('icon')).toBeNull()
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('PendingCard renders the question arm with its count', () => {
|
||||
const view = render(
|
||||
<PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
|
||||
)
|
||||
expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[
|
||||
{ kind: 'reasoning', text: 'thinking hard\nsecond line' },
|
||||
{ kind: 'tool-call', callId: 'c', name: 'bash', argsRaw: '{}' },
|
||||
{ kind: 'other', block: { type: 'mystery' } },
|
||||
]}
|
||||
streaming
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('Think')).toBeTruthy()
|
||||
expect(view.getByText('thinking hard')).toBeTruthy()
|
||||
expect(view.getByText(/未知内容块/)).toBeTruthy()
|
||||
const stopped = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
|
||||
)
|
||||
expect(stopped.getByText('已停止')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
|
||||
const settled: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 2, callId: 'c5',
|
||||
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolViewProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
|
||||
actions: { openDetails: vi.fn() },
|
||||
t: ((k: string) => k) as Translate,
|
||||
}
|
||||
const view = render(<GenericToolCard {...props} />)
|
||||
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
|
||||
expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('BashRow shows the failed pill on error results', () => {
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolViewProps = {
|
||||
callId: 'c1', toolName: 'bash', block: errorResult,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
|
||||
actions: { openDetails: vi.fn() },
|
||||
t: ((k: string) => k) as Translate,
|
||||
}
|
||||
const view = render(<BashRow {...props} />)
|
||||
expect(view.getByText('failed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('registry disposer re-entry is a no-op after the entry was already removed', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const off = registry.register('bash', (() => null) as never)
|
||||
const v1 = registry.getVersion()
|
||||
off()
|
||||
const v2 = registry.getVersion()
|
||||
off()
|
||||
expect(registry.getVersion()).toBe(v2)
|
||||
expect(v2).toBeGreaterThan(v1)
|
||||
})
|
||||
|
||||
it('registerChat registers the chat view with the stats footer and disposes cleanly', () => {
|
||||
const disposer = vi.fn()
|
||||
const calls: unknown[] = []
|
||||
const conversation = {
|
||||
registerView: (entry: unknown) => {
|
||||
calls.push(entry)
|
||||
return disposer
|
||||
},
|
||||
} as unknown as ConversationService
|
||||
const toolviews = new ToolViewRegistry()
|
||||
const off = registerChat({ conversation, toolviews, t: ((k: string) => k) as Translate })
|
||||
const entry = calls[0] as { id: string; chrome?: { footer?: unknown } }
|
||||
expect(entry.id).toBe('chat')
|
||||
// footer is a memo exotic component (object, not plain function).
|
||||
expect(entry.chrome?.footer).toBeDefined()
|
||||
off()
|
||||
expect(disposer).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
140
packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
Normal file
140
packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
// Final branch tails for the coverage gate, post slot-phase-2: apply's need()
|
||||
// throw + cwd cache hit/empty-cwd skip, AssistantMarkdown non-final reasoning,
|
||||
// StatsLine usage-less node, ChatView tool-group selected passthrough +
|
||||
// running-empty guard, DetailsPanel titleless selection, registry disposer
|
||||
// after a foreign removal emptied the list.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { Context } from 'cordis'
|
||||
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
describe('apply need() and cwd cache', () => {
|
||||
it('apply fails loud when a required service is absent', () => {
|
||||
// Call apply directly (no fiber machinery): need('sessions') on a bare
|
||||
// context throws synchronously — the loud-failure branch without the
|
||||
// fiber runner's internal rejection surface. Mount semantics (inject
|
||||
// gating) are covered by the full bench in apply-inject.spec.
|
||||
void inject
|
||||
const ctx = new Context()
|
||||
expect(() => { (apply as (c: Context) => void)(ctx) }).toThrow(/sessions service unavailable/)
|
||||
})
|
||||
|
||||
it('cwd derivation caches per list state and skips empty cwd values', async () => {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [SID, 'x2' as SessionId, 'x3' as SessionId],
|
||||
byId: {
|
||||
[SID]: { id: SID, title: 'a', cwd: '/proj', running: false, updatedAt: 1 },
|
||||
['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', cwd: '', running: false, updatedAt: 1 },
|
||||
['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', running: false, updatedAt: 1 },
|
||||
},
|
||||
})
|
||||
ctx.provide('sessions', { list: listStore, manager: { get: vi.fn() }, ancestry: () => [], scope: () => undefined, create: vi.fn() })
|
||||
ctx.provide('layout', { current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }), open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('i18n', { bind: () => (k: string) => k })
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.define('conversation', { kind: 'single', scope: 'session' })
|
||||
slots.define('details', { kind: 'single', scope: 'session' })
|
||||
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const entry = slots.entries('conversation.empty')[0]! as unknown as {
|
||||
options: { inject: (b: unknown) => { useCwds: (sel: (s: readonly string[]) => readonly string[]) => readonly string[] } }
|
||||
}
|
||||
const injected = entry.options.inject({ ctx })
|
||||
const Probe = () => {
|
||||
const cwds = injected.useCwds(s => s)
|
||||
const again = injected.useCwds(s => s)
|
||||
// Cache hit: same state object yields the same derived array reference.
|
||||
return <i data-testid="cwds">{`${cwds.join(',')}|${String(cwds === again)}`}</i>
|
||||
}
|
||||
const view = render(<Probe />)
|
||||
expect(view.getByTestId('cwds').textContent).toBe('/proj|true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('render branch tails', () => {
|
||||
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]}
|
||||
streaming
|
||||
/>,
|
||||
)
|
||||
// reasoning at index 0 with a later block: running is false → ok state.
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => {
|
||||
const snap = {
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
|
||||
// outputTokens absent: the tokens sum's ?? 0 arm for output.
|
||||
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
],
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
|
||||
)
|
||||
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
|
||||
)
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => {
|
||||
const SEL: SelectionTarget = { turnSeq: 1, callId: 'ghost' }
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snapshotBase(), subscribe: () => () => {} }) as unknown as UseSession}
|
||||
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
|
||||
actions={{ closeDetails: vi.fn() }}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('详情')).toBeTruthy()
|
||||
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('registry disposer tolerates the list already emptied by a sibling disposer', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const offA = registry.register('bash', () => null)
|
||||
const offB = registry.register('bash', () => null)
|
||||
offA()
|
||||
offB()
|
||||
// Both entries gone; a re-register works from a fresh list.
|
||||
registry.register('bash', () => null)
|
||||
expect(registry.resolve('bash', SID)).toBeDefined()
|
||||
})
|
||||
})
|
||||
131
packages/client/ui-conversation/tests/input-bar.spec.tsx
Normal file
131
packages/client/ui-conversation/tests/input-bar.spec.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
// InputBar behavior: Enter-send semantics (IME guard, shift newline,
|
||||
// ctrl/meta insert, repeat suppression), the running lock with stop-only
|
||||
// action, unlock refocus, error strip copy, and the focus-keeping mousedown.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function setup(over?: Partial<InputBarProps>) {
|
||||
const props: InputBarProps = {
|
||||
draft: 'hello', running: false, disabled: false, error: null,
|
||||
variant: 'composer',
|
||||
onDraftChange: vi.fn(), onSend: vi.fn(), onStop: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
const view = render(<InputBar {...props} />)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const button = view.container.querySelector('button')!
|
||||
return { view, textarea, button, props }
|
||||
}
|
||||
|
||||
describe('Enter semantics', () => {
|
||||
it('plain Enter sends queue mode; repeat and empty are suppressed', () => {
|
||||
const { textarea, props } = setup()
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(props.onSend).toHaveBeenCalledWith('queue')
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
|
||||
expect(props.onSend).toHaveBeenCalledTimes(1)
|
||||
const empty = setup({ draft: ' ' })
|
||||
fireEvent.keyDown(empty.textarea, { key: 'Enter' })
|
||||
expect(empty.props.onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
|
||||
const { textarea, props } = setup()
|
||||
fireEvent.keyDown(textarea, { key: 'a' })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Ctrl/Meta+Enter inserts a newline through execCommand instead of sending', () => {
|
||||
const exec = vi.fn()
|
||||
;(document as unknown as { execCommand: typeof exec }).execCommand = exec
|
||||
const { textarea, props } = setup()
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(exec).toHaveBeenCalledWith('insertText', false, '\n')
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { textarea, props } = setup()
|
||||
fireEvent.compositionStart(textarea)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
fireEvent.compositionEnd(textarea)
|
||||
// Safari delivers the closing keydown before the deferred clear.
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(20)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(props.onSend).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('running lock and primary button', () => {
|
||||
it('running locks the textarea and turns the primary into stop', () => {
|
||||
const { textarea, button, props } = setup({ running: true })
|
||||
expect(textarea.disabled).toBe(true)
|
||||
expect(button.getAttribute('aria-label')).toBe('停止')
|
||||
fireEvent.click(button)
|
||||
expect(props.onStop).toHaveBeenCalledTimes(1)
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('idle primary sends and disables on empty draft', () => {
|
||||
const { button, props } = setup()
|
||||
fireEvent.click(button)
|
||||
expect(props.onSend).toHaveBeenCalledWith('queue')
|
||||
const empty = setup({ draft: '' })
|
||||
expect(empty.button.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
|
||||
const { view, props } = setup({ running: true })
|
||||
view.rerender(<InputBar {...props} running={false} />)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
textarea.blur()
|
||||
fireEvent.mouseDown(view.container.querySelector('button')!)
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; typing forwards drafts', () => {
|
||||
const { textarea } = setup({ disabled: true, draft: '' })
|
||||
expect(textarea.placeholder).toBe('会话不可用')
|
||||
const live = setup({ draft: '' })
|
||||
expect(live.textarea.placeholder).toContain('Enter 发送')
|
||||
fireEvent.change(live.textarea, { target: { value: 'typed' } })
|
||||
expect(live.props.onDraftChange).toHaveBeenCalledWith('typed')
|
||||
const runningPh = setup({ running: true, draft: '' })
|
||||
expect(runningPh.textarea.placeholder).toContain('停止')
|
||||
const custom = setup({ placeholder: '自定义' })
|
||||
expect(custom.textarea.placeholder).toBe('自定义')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error strip and variants', () => {
|
||||
it('renders send and stop failure copy', () => {
|
||||
const send = setup({ error: { op: 'send', message: 'boom' } })
|
||||
expect(send.view.getByText(/发送失败:boom/)).toBeTruthy()
|
||||
const stop = setup({ error: { op: 'stop', message: 'halt' } })
|
||||
expect(stop.view.getByText(/停止失败:halt/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hero variant adds the hero class and accessory row renders', () => {
|
||||
const { view } = setup({ variant: 'hero', accessory: <i data-testid="acc" /> })
|
||||
expect(view.getByTestId('acc')).toBeTruthy()
|
||||
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
121
packages/client/ui-conversation/tests/selection-survival.spec.ts
Normal file
121
packages/client/ui-conversation/tests/selection-survival.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* M1a regression pin: the per-scope selection must survive list refreshes.
|
||||
* Drives the REAL SessionsService + ConversationService chain over the
|
||||
* programmable wire fake — a late list refresh that upgrades the display
|
||||
* title (bare id → cwd basename) and a reconnect-driven refreshList+resync
|
||||
* must neither recreate the session scope nor clear the selection account.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
// The runtime package's programmable fake lives in its tests; import through
|
||||
// the src path (same pattern the runtime specs use — test-support material).
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
api: FakeApiClient
|
||||
sessions: SessionsService
|
||||
conversation: ConversationService
|
||||
}
|
||||
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const conversation = new ConversationService(ctx)
|
||||
return { ctx, api, sessions, conversation }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
// Manager notifier + store batching are microtask-based.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[]): void {
|
||||
b.api.onList = () => Promise.resolve(ok({
|
||||
items: rows.map(r => ({
|
||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
})),
|
||||
}) as never)
|
||||
}
|
||||
|
||||
describe('selection survives list refreshes (M1a)', () => {
|
||||
it('create → select → title-upgrading refresh keeps scope, binding, store and value', async () => {
|
||||
const b = bench()
|
||||
// First-send shape: client-side create inserts the row without cwd (title = bare id).
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
|
||||
const id = await b.sessions.create({})
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1')
|
||||
|
||||
const binding = b.sessions.binding(id)
|
||||
expect(binding).toBeDefined()
|
||||
const scoped = b.sessions.scope(id)!
|
||||
const store = (scoped.get('conversation') as ConversationService).selection
|
||||
store.set({ turnSeq: 3, callId: 'c1' })
|
||||
|
||||
// The late list refresh lands (host knows the cwd → formal title).
|
||||
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a')
|
||||
|
||||
// Scope, binding and the selection account must all be identity-stable.
|
||||
expect(b.sessions.scope(id)).toBe(scoped)
|
||||
expect(b.sessions.binding(id)).toBe(binding)
|
||||
const after = (b.sessions.scope(id)!.get('conversation') as ConversationService).selection
|
||||
expect(after).toBe(store)
|
||||
expect(after.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
})
|
||||
|
||||
it('reconnect (handleConnected: refreshList + resync) keeps the selection account', async () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const scoped = b.sessions.scope(sid('s1'))!
|
||||
const store = (scoped.get('conversation') as ConversationService).selection
|
||||
store.set({ turnSeq: 1, callId: 'c9' })
|
||||
|
||||
// Reconnect generation: title upgrade arrives with the re-pull.
|
||||
feed(b, [{ id: 's1', cwd: '/w/proj-a', running: true }])
|
||||
b.sessions.manager.handleConnected()
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
|
||||
const after = (b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection
|
||||
expect(after).toBe(store)
|
||||
expect(after.getSnapshot()).toEqual({ turnSeq: 1, callId: 'c9' })
|
||||
})
|
||||
|
||||
it('a transiently failing list refresh does not prune live scopes', async () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
const scoped = b.sessions.scope(sid('s1'))!
|
||||
const store = (scoped.get('conversation') as ConversationService).selection
|
||||
store.set({ turnSeq: 2, callId: 'c2' })
|
||||
|
||||
// Wire hiccup: the reconnect-time list RPC throws (transport error).
|
||||
b.api.onList = () => Promise.reject(new Error('boom'))
|
||||
b.sessions.manager.handleConnected()
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
|
||||
expect((b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection.getSnapshot())
|
||||
.toEqual({ turnSeq: 2, callId: 'c2' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ConversationService orchestration half: scope-addressed send/cancel (result
|
||||
* folding, root throw), openDetails choreography, the startSession chain, and
|
||||
* the service-unavailable loud failures. Store semantics live in
|
||||
* service-stores.spec.ts.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/** Recover the module-private scope tag through the public seam (same probe as service-stores.spec). */
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver): unknown {
|
||||
recorded.push(prop)
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(spy)
|
||||
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
||||
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
||||
return symbol
|
||||
})()
|
||||
|
||||
interface SessionDouble {
|
||||
prompt: ReturnType<typeof vi.fn>
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
||||
const ctx = new Context()
|
||||
const sessionDoubles = new Map<SessionId, SessionDouble>()
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
if (scoped === undefined) {
|
||||
const fiber = ctx.plugin(() => {})
|
||||
scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
|
||||
scopes.set(id, scoped)
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
|
||||
const sessionsFake = {
|
||||
manager: {
|
||||
get: (id: SessionId) => {
|
||||
let s = sessionDoubles.get(id)
|
||||
if (s === undefined) {
|
||||
s = {
|
||||
prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
}
|
||||
sessionDoubles.set(id, s)
|
||||
}
|
||||
return s
|
||||
},
|
||||
},
|
||||
create: createMock,
|
||||
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
|
||||
} as unknown as SessionsService
|
||||
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
|
||||
const layoutFake = { open: vi.fn(), openDetails: vi.fn() }
|
||||
if (opts?.layout !== false) ctx.provide('layout', layoutFake)
|
||||
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
|
||||
await fiber.await()
|
||||
const svc = ctx.get('conversation') as ConversationService
|
||||
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
|
||||
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, layoutFake }
|
||||
}
|
||||
|
||||
describe('send / cancel', () => {
|
||||
it('sends one text block through the scoped session with the mode', async () => {
|
||||
const b = await bench()
|
||||
await b.scopedSvc(sid('s1')).send('hello', 'steer')
|
||||
expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith(
|
||||
[{ type: 'text', text: 'hello' }], 'steer')
|
||||
})
|
||||
|
||||
it('folds business failure into a thrown error carrying code and message', async () => {
|
||||
const b = await bench()
|
||||
const s = b.scopedSvc(sid('s1'))
|
||||
// Materialize the double first (manager.get is the lazy mint point).
|
||||
b.sessionsFake.manager.get(sid('s1'))
|
||||
const double = b.sessionDoubles.get(sid('s1'))!
|
||||
double.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'busy' } })
|
||||
await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/)
|
||||
})
|
||||
|
||||
it('cancel resolves on ok and throws the folded business error', async () => {
|
||||
const b = await bench()
|
||||
const s = b.scopedSvc(sid('s1'))
|
||||
await s.cancel()
|
||||
const double = b.sessionDoubles.get(sid('s1'))!
|
||||
expect(double.cancel).toHaveBeenCalledTimes(1)
|
||||
double.cancel.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'nope' } })
|
||||
await expect(s.cancel()).rejects.toThrow(/cancel failed: internal: nope/)
|
||||
})
|
||||
|
||||
it('root-context send and cancel throw the addressing hint', async () => {
|
||||
const b = await bench()
|
||||
await expect(b.svc.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||
await expect(b.svc.cancel()).rejects.toThrow(/requires a session scope/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openDetails', () => {
|
||||
it('writes the scoped selection then opens the layout panel', async () => {
|
||||
const b = await bench()
|
||||
const s = b.scopedSvc(sid('s1'))
|
||||
s.openDetails({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
|
||||
expect(s.selection.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
|
||||
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSession chain', () => {
|
||||
it('creates, navigates, then sends through the new scope', async () => {
|
||||
const b = await bench()
|
||||
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
|
||||
expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
expect(b.layoutFake.open).toHaveBeenCalledWith(sid('new-1'))
|
||||
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
|
||||
[{ type: 'text', text: 'first' }], 'queue')
|
||||
})
|
||||
|
||||
it('omits cwd from create when not chosen', async () => {
|
||||
const b = await bench()
|
||||
await b.svc.startSession({ text: 't', mode: 'steer' })
|
||||
expect(b.createMock).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it('fails loud when the created session resolves no scope', async () => {
|
||||
const b = await bench()
|
||||
;(b.sessionsFake.create as ReturnType<typeof vi.fn>).mockResolvedValue(sid('ghost'))
|
||||
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/resolved no scope/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('service-unavailable loud failures', () => {
|
||||
it('throws when sessions is missing', async () => {
|
||||
const b = await bench({ sessions: false })
|
||||
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/)
|
||||
})
|
||||
|
||||
it('throws when layout is missing', async () => {
|
||||
const b = await bench({ layout: false })
|
||||
const s = b.scopedSvc(sid('s1'))
|
||||
expect(() => { s.openDetails({ turnSeq: 1 }) }).toThrow(/layout service unavailable/)
|
||||
})
|
||||
|
||||
it('startSession fails loud when the new scope cannot resolve conversation', async () => {
|
||||
const b = await bench()
|
||||
// A scope minted outside the service tree: scoped.get('conversation') finds nothing.
|
||||
const foreign = new Context()
|
||||
const foreignScope = foreign.plugin(() => {}).ctx.extend({})
|
||||
;(b.sessionsFake.scope as unknown) = () => foreignScope
|
||||
await expect(b.svc.startSession({ text: 't', mode: 'queue' }))
|
||||
.rejects.toThrow(/conversation service unavailable through the new scope/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('views ordering and draft persistence branches', () => {
|
||||
it('orders by explicit order with undefined treated as zero (both comparator arms)', async () => {
|
||||
const b = await bench()
|
||||
const entry = (id: string, order?: number) => ({
|
||||
id, label: id, component: () => null,
|
||||
...(order !== undefined ? { order } : {}),
|
||||
})
|
||||
b.svc.registerView(entry('z-late', 5) as never)
|
||||
b.svc.registerView(entry('default-zero') as never)
|
||||
b.svc.registerView(entry('first', -1) as never)
|
||||
expect(b.svc.views().map(v => v.id)).toEqual(['first', 'default-zero', 'z-late'])
|
||||
})
|
||||
|
||||
it('draft store round-trips through localStorage and removes the key when emptied', async () => {
|
||||
const b = await bench()
|
||||
localStorage.setItem('dsh.conversation.draft.s9', 'restored')
|
||||
const s = b.scopedSvc(sid('s9'))
|
||||
expect(s.drafts.getSnapshot()).toBe('restored')
|
||||
s.drafts.set('typed')
|
||||
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBe('typed')
|
||||
s.drafts.set('')
|
||||
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBeNull()
|
||||
})
|
||||
})
|
||||
176
packages/client/ui-conversation/tests/service-stores.spec.ts
Normal file
176
packages/client/ui-conversation/tests/service-stores.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ConversationService store half: scope-addressed selection/drafts accounts
|
||||
* (lazy mint, per-scope isolation, root access throws, scope teardown
|
||||
* collects), view registry (order, duplicate throw, effect-scoped disposal,
|
||||
* uSES read face). Send/cancel/startSession orchestration live in
|
||||
* service-orchestration.spec.ts.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConvViewProps, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/**
|
||||
* The scope tag symbol is module-private to the runtime package; recover it
|
||||
* through the public seam by recording which symbol scopeOf reads off a
|
||||
* spying proxy (keeps this bench honest against the real tagging shape
|
||||
* without dragging the full SessionsService + wire fake in here).
|
||||
*/
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver): unknown {
|
||||
recorded.push(prop)
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(spy)
|
||||
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
||||
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
||||
return symbol
|
||||
})()
|
||||
|
||||
/** Scope bench: real cordis scope fibers tagged like SessionsService.resolve mints them. */
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
svc: ConversationService
|
||||
mint: (id: SessionId) => Context
|
||||
dispose: (id: SessionId) => Promise<void>
|
||||
}
|
||||
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
const fibers = new Map<SessionId, { fiber: ReturnType<Context['plugin']>; ctx: Context }>()
|
||||
const mint = (id: SessionId): Context => {
|
||||
let rec = fibers.get(id)
|
||||
if (rec === undefined) {
|
||||
const fiber = ctx.plugin(() => {})
|
||||
const scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
|
||||
rec = { fiber, ctx: scoped }
|
||||
fibers.set(id, rec)
|
||||
}
|
||||
return rec.ctx
|
||||
}
|
||||
const dispose = async (id: SessionId): Promise<void> => {
|
||||
const rec = fibers.get(id)
|
||||
if (rec !== undefined) {
|
||||
await rec.fiber.dispose()
|
||||
fibers.delete(id)
|
||||
}
|
||||
}
|
||||
const sessions = { scope: (id: SessionId) => fibers.get(id)?.ctx } as unknown as SessionsService
|
||||
ctx.provide('sessions', sessions)
|
||||
const svc = new ConversationService(ctx)
|
||||
return { ctx, svc, mint, dispose }
|
||||
}
|
||||
|
||||
/** Scoped service view: ctx.get binds the root singleton to the scoped ctx (scope addressing seam). */
|
||||
function convo(scoped: Context): ConversationService {
|
||||
const service = scoped.get('conversation')
|
||||
if (service === undefined) throw new Error('bench: conversation unavailable')
|
||||
return service
|
||||
}
|
||||
|
||||
const viewComp = (() => null) as unknown as FC<ConvViewProps>
|
||||
const entry = (id: string, order?: number): ViewEntry =>
|
||||
({ id, label: id, component: viewComp, ...(order !== undefined ? { order } : {}) }) as unknown as ViewEntry
|
||||
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
describe('scope addressing of stores', () => {
|
||||
it('root-context selection/drafts access throws with the addressing hint', () => {
|
||||
const b = bench()
|
||||
expect(() => b.svc.selection).toThrow(/requires a session scope/)
|
||||
expect(() => b.svc.drafts).toThrow(/requires a session scope/)
|
||||
})
|
||||
|
||||
it('mints one store per scope and keeps identity per session', () => {
|
||||
const b = bench()
|
||||
const c1 = b.mint(sid('s1'))
|
||||
const c2 = b.mint(sid('s2'))
|
||||
const sel1 = convo(c1).selection
|
||||
const sel2 = convo(c2).selection
|
||||
expect(sel1).not.toBe(sel2)
|
||||
expect(convo(c1).selection).toBe(sel1)
|
||||
sel1.set({ turnSeq: 3 })
|
||||
expect(sel1.getSnapshot()).toEqual({ turnSeq: 3 })
|
||||
expect(sel2.getSnapshot()).toBeNull()
|
||||
})
|
||||
|
||||
it('persists drafts keyed by session id and evolves independently', async () => {
|
||||
const b = bench()
|
||||
const c1 = b.mint(sid('s1'))
|
||||
convo(c1).drafts.set('hello')
|
||||
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBe('hello')
|
||||
const c2 = b.mint(sid('s2'))
|
||||
expect(convo(c2).drafts.getSnapshot()).toBe('')
|
||||
// Re-minting after teardown rehydrates from storage; clearing removes the key.
|
||||
await b.dispose(sid('s1'))
|
||||
expect(convo(b.mint(sid('s1'))).drafts.getSnapshot()).toBe('hello')
|
||||
convo(b.mint(sid('s1'))).drafts.set('')
|
||||
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBeNull()
|
||||
})
|
||||
|
||||
it('scope fiber disposal collects the store account (fresh store on re-mint)', async () => {
|
||||
const b = bench()
|
||||
const c1 = b.mint(sid('s1'))
|
||||
const sel = convo(c1).selection
|
||||
sel.set({ turnSeq: 1 })
|
||||
await b.dispose(sid('s1'))
|
||||
const again = b.mint(sid('s1'))
|
||||
const sel2 = convo(again).selection
|
||||
expect(sel2).not.toBe(sel)
|
||||
expect(sel2.getSnapshot()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('view registry', () => {
|
||||
it('orders by order (ties keep registration sequence) with a stable cache reference', () => {
|
||||
const b = bench()
|
||||
b.svc.registerView(entry('chat', 0))
|
||||
b.svc.registerView(entry('waterfall', 2))
|
||||
b.svc.registerView(entry('trajectory', 1))
|
||||
const views = b.svc.views()
|
||||
expect(views.map(v => v.id)).toEqual(['chat', 'trajectory', 'waterfall'])
|
||||
expect(b.svc.views()).toBe(views)
|
||||
})
|
||||
|
||||
it('duplicate id throws; disposer removes and bumps the version', () => {
|
||||
const b = bench()
|
||||
const fn = vi.fn()
|
||||
b.svc.subscribeViews(fn)
|
||||
const off = b.svc.registerView(entry('chat'))
|
||||
expect(() => b.svc.registerView(entry('chat'))).toThrow(/already registered/)
|
||||
const v1 = b.svc.viewsVersion()
|
||||
off()
|
||||
expect(b.svc.viewsVersion()).toBeGreaterThan(v1)
|
||||
expect(b.svc.views()).toEqual([])
|
||||
expect(fn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unsubscribe stops notifications', () => {
|
||||
const b = bench()
|
||||
const fn = vi.fn()
|
||||
const unsub = b.svc.subscribeViews(fn)
|
||||
unsub()
|
||||
b.svc.registerView(entry('chat'))
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a registering plugin fiber unloading collects its views (effect cascade)', async () => {
|
||||
const b = bench()
|
||||
const fiber = b.ctx.plugin((pluginCtx: Context) => {
|
||||
convo(pluginCtx).registerView(entry('chat'))
|
||||
})
|
||||
await fiber.await()
|
||||
expect(b.svc.views().map(v => v.id)).toEqual(['chat'])
|
||||
await fiber.dispose()
|
||||
expect(b.svc.views()).toEqual([])
|
||||
})
|
||||
})
|
||||
245
packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
Normal file
245
packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// @vitest-environment jsdom
|
||||
// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx
|
||||
// acceptance flows): breadcrumb ancestry rendering + error strip in
|
||||
// ConversationRoot, DetailsPanel non-JSON args / non-text result blocks /
|
||||
// error-only results, EmptyState failure surface and custom-directory swap.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationRoot, DetailsPanel, EmptyState } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
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<ConversationSnapshot>) {
|
||||
const snap = { ...snapshotBase(), ...over }
|
||||
return {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
}
|
||||
|
||||
const summary = (id: string, title: string): SessionSummary =>
|
||||
({ id: id as SessionId, title, running: false, updatedAt: 1 })
|
||||
|
||||
describe('ConversationRoot branches', () => {
|
||||
const chatEntry: ViewEntry = {
|
||||
id: 'chat', label: 'Chat', component: () => null,
|
||||
} as unknown as ViewEntry
|
||||
|
||||
function rootProps(over?: {
|
||||
ancestry?: readonly SessionSummary[]
|
||||
snapshot?: Partial<ConversationSnapshot>
|
||||
}) {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
<ConversationRoot
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession}
|
||||
useAncestry={() => over?.ancestry ?? []}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
useActiveView={() => undefined}
|
||||
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
|
||||
actions={{ openView: vi.fn(), open }}
|
||||
renderView={() => <div data-testid="view-body" />}
|
||||
/>,
|
||||
)
|
||||
return { view, open }
|
||||
}
|
||||
|
||||
it('renders the ancestry breadcrumb with separators and navigates on ancestor click', () => {
|
||||
const { view, open } = rootProps({
|
||||
ancestry: [summary('root-1', 'Workspace'), summary('s1', 'Current')],
|
||||
})
|
||||
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('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 active view id falls back to the first registered view', () => {
|
||||
const view = render(
|
||||
<ConversationRoot
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession}
|
||||
useAncestry={() => []}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
useActiveView={() => 'gone' as never}
|
||||
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
|
||||
actions={{ openView: vi.fn(), open: vi.fn() }}
|
||||
renderView={(entry) => <div data-testid={`body-${entry.id}`} />}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByTestId('body-chat')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel branches', () => {
|
||||
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession}
|
||||
useSelection={bindSnapshotSelector({ getSnapshot: () => selection, subscribe: () => () => {} })}
|
||||
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, 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, callView: null }] } as ConversationSnapshot
|
||||
const subs = new Set<() => void>()
|
||||
const source = {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: (fn: () => void) => {
|
||||
subs.add(fn)
|
||||
return () => subs.delete(fn)
|
||||
},
|
||||
}
|
||||
const SEL: SelectionTarget = { turnSeq: 1, callId: 'c9' }
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector(source) as unknown as UseSession}
|
||||
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
|
||||
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', () => {
|
||||
// getSnapshot must return a stable reference (uSES contract) — a fresh
|
||||
// array per call loops the selector forever.
|
||||
const CWDS: readonly string[] = ['/proj']
|
||||
const NO_CWDS: readonly string[] = []
|
||||
|
||||
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(
|
||||
<EmptyState
|
||||
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
|
||||
actions={{ startSession }}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<EmptyState
|
||||
useCwds={bindSnapshotSelector({ getSnapshot: () => NO_CWDS, subscribe: () => () => {} })}
|
||||
actions={{ startSession }}
|
||||
/>,
|
||||
)
|
||||
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 select picks an option, swaps to free-form on 新目录, and submits the typed path', async () => {
|
||||
const startSession = vi.fn(() => Promise.resolve())
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
|
||||
actions={{ startSession }}
|
||||
/>,
|
||||
)
|
||||
const select = view.container.querySelector('select')!
|
||||
fireEvent.change(select, { target: { value: '/proj' } })
|
||||
expect((select as HTMLSelectElement).value).toBe('/proj')
|
||||
fireEvent.change(select, { target: { value: '::new-directory' } })
|
||||
const custom = view.container.querySelector('input')!
|
||||
fireEvent.change(custom, { target: { value: '/typed/dir' } })
|
||||
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' }))
|
||||
})
|
||||
})
|
||||
181
packages/client/ui-conversation/tests/skeleton.spec.tsx
Normal file
181
packages/client/ui-conversation/tests/skeleton.spec.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Skeleton acceptance: empty-state transition (same InputBar component in
|
||||
* hero position, startSession submit), ConversationRoot view switching over
|
||||
* the registry face, DetailsPanel open/close linkage against a layout-shaped
|
||||
* fake. Components stay framework-free — everything arrives via props here,
|
||||
* exactly as the inject factories will assemble them.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
ConversationRoot, DetailsPanel, EmptyState,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** Minimal conversation snapshot slice the skeleton reads. */
|
||||
interface FakeSnapshot {
|
||||
nodes: readonly { kind: string; callId?: string; call?: { name: string; argsRaw: string } | null; content?: readonly { type: string; text?: string }[]; isError?: boolean }[]
|
||||
runningCalls: readonly { callId: string; name: string; argsRaw: string }[]
|
||||
running: boolean
|
||||
removed: boolean
|
||||
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
||||
}
|
||||
|
||||
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
||||
const store = createSnapshotStore<FakeSnapshot>({
|
||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
|
||||
}
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('submits startSession with the typed text and picked cwd; failure surfaces locally', async () => {
|
||||
const cwds = createSnapshotStore<readonly string[]>(['/w/app', '/w/lib'])
|
||||
let reject!: (e: Error) => void
|
||||
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
||||
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession }} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '项目目录' }), { target: { value: '/w/app' } })
|
||||
const box = screen.getByPlaceholderText('Message to run task, plan and build')
|
||||
fireEvent.change(box, { target: { value: '造一个轮子' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
|
||||
|
||||
reject(new Error('后端拒收'))
|
||||
expect(await screen.findByText(/后端拒收/)).toBeTruthy()
|
||||
// Draft survives the failure for retry.
|
||||
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
|
||||
})
|
||||
|
||||
it('new-directory option swaps the select for a free-form input', () => {
|
||||
const cwds = createSnapshotStore<readonly string[]>([])
|
||||
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession: () => Promise.resolve() }} />)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
|
||||
const custom = screen.getByPlaceholderText(/目录路径/)
|
||||
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
|
||||
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationRoot', () => {
|
||||
function bench(views: ViewEntry[], active?: string) {
|
||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
|
||||
const activeStore = createSnapshotStore<string | undefined>(active)
|
||||
const openView = vi.fn((v: string) => { activeStore.set(v) })
|
||||
const open = vi.fn()
|
||||
const drafts = createSnapshotStore<string>('')
|
||||
const send = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const ancestry: SessionSummary[] = [
|
||||
{ id: sid('root'), title: 'proj', running: false, updatedAt: 1 },
|
||||
{ id: sid('s1'), title: 'child', running: false, updatedAt: 1, parentId: sid('root') },
|
||||
]
|
||||
const rendered: string[] = []
|
||||
const ui = render(
|
||||
<ConversationRoot
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useAncestry={() => ancestry}
|
||||
views={{
|
||||
list: () => views,
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
useActiveView={() => activeStore.useSelector(s => s) as ViewId | undefined}
|
||||
composer={{
|
||||
useDraft: () => drafts.useSelector(s => s),
|
||||
setDraft: (t) => { drafts.set(t) },
|
||||
send, stop,
|
||||
}}
|
||||
actions={{ openView: openView as (v: never) => void, open }}
|
||||
renderView={(entry) => { rendered.push(entry.id); return <div data-testid={`view-${entry.id}`} /> }}
|
||||
/>)
|
||||
return { ui, openView, open, rendered, send, drafts }
|
||||
}
|
||||
|
||||
const comp = (() => null) as unknown as FC<never>
|
||||
const view = (id: string, label: string): ViewEntry =>
|
||||
({ id, label, component: comp }) as unknown as ViewEntry
|
||||
|
||||
it('renders breadcrumb chain, meta turns, and the active view (default chat)', () => {
|
||||
const { rendered, open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('child')).toBeTruthy()
|
||||
expect(screen.getByText(/2 turns/)).toBeTruthy()
|
||||
expect(rendered).toEqual(['chat'])
|
||||
// Ancestor crumb navigates; current crumb is disabled.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
|
||||
expect(open).toHaveBeenCalledWith('root')
|
||||
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('switches views through actions.openView and re-renders the new body', () => {
|
||||
const { openView } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(openView).toHaveBeenCalledWith('trajectory')
|
||||
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the tab strip with a single view and wires the composer send', () => {
|
||||
const { send } = bench([view('chat', 'Chat')])
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
const box = screen.getByPlaceholderText(/输入消息/)
|
||||
fireEvent.change(box, { target: { value: 'hi' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledWith('queue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel', () => {
|
||||
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
|
||||
const { useSession } = fakeSession(snapshot)
|
||||
const selectionStore = createSnapshotStore<SelectionTarget | null>(selection)
|
||||
const closeDetails = vi.fn()
|
||||
render(
|
||||
<DetailsPanel
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useSelection={selectionStore.useSelector}
|
||||
actions={{ closeDetails }}
|
||||
/>)
|
||||
return { closeDetails, selectionStore }
|
||||
}
|
||||
|
||||
it('renders the selected call args and result; close fires the layout-linked action', () => {
|
||||
const { closeDetails } = benchDetails({
|
||||
nodes: [{
|
||||
kind: 'tool-result', callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
|
||||
content: [{ type: 'text', text: 'file-a\nfile-b' }],
|
||||
isError: false,
|
||||
}],
|
||||
}, { turnSeq: 1, callId: 'c1' })
|
||||
expect(screen.getByText('bash')).toBeTruthy()
|
||||
expect(screen.getByText(/"cmd": "ls"/)).toBeTruthy()
|
||||
expect(screen.getByText(/file-a/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
|
||||
expect(closeDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the empty hint without a selection and the running state for open calls', () => {
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, null)
|
||||
expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
|
||||
cleanup()
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, { turnSeq: 1, callId: 'c9' })
|
||||
expect(screen.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports an out-of-window call distinctly', () => {
|
||||
benchDetails({}, { turnSeq: 1, callId: 'ghost' })
|
||||
expect(screen.getByText(/不在当前窗口内/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Tool-ring Entry typing (design §7): I inferred from the inject factory at
|
||||
* the register site, component must accept ToolViewProps & I, and the resolve
|
||||
* read face carries the erased-but-present inject. Compile-time checks via
|
||||
* @ts-expect-error pairs; the runtime assertions just keep vitest happy.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
// Positive control: component's own injected share matches the factory's product.
|
||||
interface RowInjected { useMyStore: () => number }
|
||||
const InjectedRowComp: FC<ToolViewProps & RowInjected> = () => null
|
||||
// Plain rows take the shared props only.
|
||||
const PlainRowComp: FC<ToolViewProps> = () => null
|
||||
|
||||
describe('tool-ring entry typing', () => {
|
||||
it('register infers I from the inject factory and accepts a matching component', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const off = reg.register('bash', InjectedRowComp, {
|
||||
inject: () => ({ useMyStore: () => 1 }),
|
||||
})
|
||||
expect(reg.resolve('bash', sid('s'))?.inject).toBeDefined()
|
||||
off()
|
||||
})
|
||||
|
||||
it('injectless registration needs no options and resolves without inject', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
reg.register('read', PlainRowComp)
|
||||
expect('inject' in (reg.resolve('read', sid('s')) ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('compile-time: factory product must cover the component injected share', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
reg.register('bash', InjectedRowComp, {
|
||||
// @ts-expect-error the factory misses useMyStore, which the component requires
|
||||
inject: () => ({ somethingElse: 1 }),
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
// Known boundary (not asserted): a component demanding an injected share CAN
|
||||
// register bare — with I defaulting to `object`, FC<ToolViewProps & RowInjected>
|
||||
// is structurally assignable to FC<ToolViewProps & object> (parameter
|
||||
// bivariance over a wider props type). The register-site guarantee holds in
|
||||
// the direction that matters: WITH an inject factory, its product must cover
|
||||
// the component's share (previous case). The bare-register gap is the same
|
||||
// one SlotMap's single-kind register has and is accepted by design §7.
|
||||
|
||||
it('compile-time: scope filter receives the branded SessionId', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
reg.register('bash', PlainRowComp, {
|
||||
// @ts-expect-error number is not assignable to SessionId
|
||||
scope: (id: number) => id > 0,
|
||||
})
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
101
packages/client/ui-conversation/tests/toolview-registry.spec.ts
Normal file
101
packages/client/ui-conversation/tests/toolview-registry.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
const comp = (name: string) => {
|
||||
const fc = () => null
|
||||
fc.displayName = name
|
||||
return fc as unknown as import('react').FC<ToolViewProps>
|
||||
}
|
||||
|
||||
describe('ToolViewRegistry', () => {
|
||||
it('resolves a global registration for any session', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const bash = comp('Bash')
|
||||
reg.register('bash', bash)
|
||||
expect(reg.resolve('bash', sid('a'))?.component).toBe(bash)
|
||||
expect(reg.resolve('bash', sid('b'))?.component).toBe(bash)
|
||||
expect(reg.resolve('read', sid('a'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers a matching scope filter over the global registration', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const global = comp('Global')
|
||||
const swarm = comp('Swarm')
|
||||
reg.register('bash', global)
|
||||
reg.register('bash', swarm, { scope: id => id === sid('swarm-1') })
|
||||
expect(reg.resolve('bash', sid('swarm-1'))?.component).toBe(swarm)
|
||||
expect(reg.resolve('bash', sid('plain'))?.component).toBe(global)
|
||||
})
|
||||
|
||||
it('later registration wins within the same tier, scoped and global', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const s1 = comp('S1')
|
||||
const s2 = comp('S2')
|
||||
const g1 = comp('G1')
|
||||
const g2 = comp('G2')
|
||||
reg.register('bash', g1)
|
||||
reg.register('bash', s1, { scope: () => true })
|
||||
reg.register('bash', s2, { scope: () => true })
|
||||
reg.register('bash', g2)
|
||||
expect(reg.resolve('bash', sid('x'))?.component).toBe(s2)
|
||||
const scopeless = new ToolViewRegistry()
|
||||
scopeless.register('bash', g1)
|
||||
scopeless.register('bash', g2)
|
||||
expect(scopeless.resolve('bash', sid('x'))?.component).toBe(g2)
|
||||
})
|
||||
|
||||
it('a non-matching scope filter falls through to global, then undefined', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const scoped = comp('Scoped')
|
||||
reg.register('bash', scoped, { scope: () => false })
|
||||
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
|
||||
const global = comp('Global')
|
||||
reg.register('bash', global)
|
||||
expect(reg.resolve('bash', sid('x'))?.component).toBe(global)
|
||||
})
|
||||
|
||||
it('disposer removes exactly its registration and is idempotent', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const g = comp('G')
|
||||
const s = comp('S')
|
||||
const off = reg.register('bash', s, { scope: () => true })
|
||||
reg.register('bash', g)
|
||||
off()
|
||||
off()
|
||||
expect(reg.resolve('bash', sid('x'))?.component).toBe(g)
|
||||
})
|
||||
|
||||
it('unregistering the last entry resolves undefined (GenericToolCard fallback)', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const off = reg.register('bash', comp('B'))
|
||||
off()
|
||||
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries the inject factory through resolve', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const inject = () => ({})
|
||||
reg.register('bash', comp('B'), { inject })
|
||||
expect(reg.resolve('bash', sid('x'))?.inject).toBe(inject)
|
||||
reg.register('read', comp('R'))
|
||||
expect('inject' in reg.resolve('read', sid('x'))!).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies subscribers and bumps the version on register and dispose', () => {
|
||||
const reg = new ToolViewRegistry()
|
||||
const fn = vi.fn()
|
||||
const unsub = reg.subscribe(fn)
|
||||
const v0 = reg.getVersion()
|
||||
const off = reg.register('bash', comp('B'))
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
expect(reg.getVersion()).toBeGreaterThan(v0)
|
||||
off()
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
unsub()
|
||||
reg.register('read', comp('R'))
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// Tool-ring type-chain samples (design §9 item 5, toolviews half): the
|
||||
// register→inject→resolve chain where `I` is inferred from the inject
|
||||
// factory and proved against the component at the register site, plus
|
||||
// expect-error duals. Tool names stay an open set (no per-tool props table —
|
||||
// design §7); the strong typing under test is Entry-internal. The known
|
||||
// bare-register variance edge (FC<Props & I> assignable to FC<Props & object>
|
||||
// without an inject factory) is accepted by design §7 and deliberately not
|
||||
// pinned here. Follows the slots-ring exemplar's shape.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolViewOptions, ToolViewProps } from '../src/client/contract/toolview.ts'
|
||||
import { ToolViewRegistry } from '../src/client/toolviews/registry.ts'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/** Registrant's own injected share (locally declared — ownership rule). */
|
||||
interface RowInjected { useRuns: () => number; actions2: { rerun: () => void } }
|
||||
|
||||
const InjectedRow: FC<ToolViewProps & RowInjected> = () => null
|
||||
const PlainRow: FC<ToolViewProps> = () => null
|
||||
|
||||
describe('tool-ring type-chain negatives (compile-time; body never runs)', () => {
|
||||
it('holds the negative samples as expect-error sites', () => {
|
||||
const negatives = (registry: ToolViewRegistry) => {
|
||||
// 1. Inject factory under-produces the component's declared share:
|
||||
// I infers from the factory, and the component position then fails.
|
||||
registry.register(
|
||||
'bash',
|
||||
// @ts-expect-error component wants actions2, which the factory never produces
|
||||
InjectedRow,
|
||||
{ inject: () => ({ useRuns: () => 1 }) },
|
||||
)
|
||||
// 2. Inject factory produces a drifted value type for a declared key
|
||||
// (I infers from the component position here, so TS flags the factory).
|
||||
registry.register(
|
||||
'bash',
|
||||
InjectedRow,
|
||||
// @ts-expect-error useRuns returns string here, component wants number
|
||||
{ inject: () => ({ useRuns: () => 'one', actions2: { rerun: () => {} } }) },
|
||||
)
|
||||
// 3. Options object drifts: scope filter with a wrong parameter shape.
|
||||
const badScope: ToolViewOptions<RowInjected> = {
|
||||
// @ts-expect-error scope takes a SessionId, not a numeric index
|
||||
scope: (index: number) => index > 0,
|
||||
}
|
||||
void badScope
|
||||
// 4. Component demanding props outside ToolViewProps & I (a key neither
|
||||
// standard nor injected) cannot register even with a full factory.
|
||||
const Overreaching: FC<ToolViewProps & RowInjected & { fromNowhere: boolean }> = () => null
|
||||
registry.register(
|
||||
'bash',
|
||||
// @ts-expect-error fromNowhere is neither a standard prop nor produced by the factory
|
||||
Overreaching,
|
||||
{ inject: (): RowInjected => ({ useRuns: () => 1, actions2: { rerun: () => {} } }) },
|
||||
)
|
||||
return null as ReactNode
|
||||
}
|
||||
expect(negatives).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-ring full chain (positive dual)', () => {
|
||||
it('registers with an inferred inject share, resolves by scope order, and reads the erased face back', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
// Registration: I inferred from the factory, component proved ⊇ ToolViewProps & I.
|
||||
const disposeGlobal = registry.register('bash', InjectedRow, {
|
||||
inject: (b: SessionBinding): RowInjected => ({
|
||||
useRuns: () => b.sessionId.length,
|
||||
actions2: { rerun: () => {} },
|
||||
}),
|
||||
})
|
||||
const disposeScoped = registry.register('bash', PlainRow, {
|
||||
scope: id => id === sid('swarm-1'),
|
||||
})
|
||||
|
||||
// Resolve: scope match beats global; elsewhere the global row wins.
|
||||
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(PlainRow)
|
||||
const global = registry.resolve('bash', sid('other'))
|
||||
expect(global?.component).toBe(InjectedRow)
|
||||
// Read face: I is erased to object, the factory reference survives; the
|
||||
// outlet-side restoration is the budgeted cast (same boundary as slots).
|
||||
const injected = (global?.inject as (b: SessionBinding) => RowInjected)(
|
||||
{ sessionId: 'ab', session: { useSelector: undefined }, ctx: undefined },
|
||||
)
|
||||
expect(injected.useRuns()).toBe(2)
|
||||
// Unknown tool → undefined (caller falls back to the generic card).
|
||||
expect(registry.resolve('ghost-tool', sid('other'))).toBeUndefined()
|
||||
|
||||
disposeScoped()
|
||||
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(InjectedRow)
|
||||
disposeGlobal()
|
||||
expect(registry.resolve('bash', sid('other'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
100
packages/client/ui-conversation/tests/views-type-chain.spec.tsx
Normal file
100
packages/client/ui-conversation/tests/views-type-chain.spec.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// View-ring type-chain samples (design §9 item 5, views half): the
|
||||
// register→inject→render chain composed through ConversationViewMap's
|
||||
// per-view extension shapes, plus expect-error duals for each stage.
|
||||
// Follows the slots-ring exemplar (ui-slots/tests/type-chain.spec.tsx):
|
||||
// negatives live in a never-executed function body; the positive dual runs
|
||||
// the real ConversationService view registry.
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type {
|
||||
ChromePropsOf, ConvViewProps, ConvViewPropsOf, ViewEntry,
|
||||
} from '../src/client/contract/views.ts'
|
||||
import { ConversationService } from '../src/client/service.ts'
|
||||
|
||||
// Test-only view keys with distinct extension shapes (merged like
|
||||
// ui-trajectory does; extension fields are optional per ViewEntryDef).
|
||||
declare module '../src/client/contract/views.ts' {
|
||||
interface ConversationViewMap {
|
||||
'vt-extended': { chromeProps: { statLabel: string }; extraProps: { density: 'compact' | 'wide' } }
|
||||
'vt-plain': object
|
||||
}
|
||||
}
|
||||
|
||||
const ExtendedView: FC<ConvViewPropsOf<'vt-extended'>> = ({ density }) => (density === 'compact' ? null : null)
|
||||
const ExtendedChrome: FC<ChromePropsOf<'vt-extended'>> = ({ statLabel }) => (statLabel === '' ? null : null)
|
||||
const PlainView: FC<ConvViewPropsOf<'vt-plain'>> = () => null
|
||||
|
||||
describe('view-ring type-chain negatives (compile-time; body never runs)', () => {
|
||||
it('holds the negative samples as expect-error sites', () => {
|
||||
const negatives = (service: ConversationService) => {
|
||||
// 1. Registration: a component missing the entry's declared extraProps
|
||||
// cannot register under that id (props flow from the map entry).
|
||||
const NarrowComp: FC<ConvViewProps & { density: number }> = () => null
|
||||
service.registerView({
|
||||
id: 'vt-extended',
|
||||
label: 'x',
|
||||
// @ts-expect-error density has the wrong value type vs the map entry's extraProps
|
||||
component: NarrowComp,
|
||||
})
|
||||
// 2. Registration: chrome typed for another view's chromeProps drifts.
|
||||
service.registerView({
|
||||
id: 'vt-plain',
|
||||
label: 'x',
|
||||
component: PlainView,
|
||||
// @ts-expect-error vt-plain declares no statLabel chromeProps
|
||||
chrome: { footer: ExtendedChrome },
|
||||
})
|
||||
// 3. Registration: id outside the map is rejected at the entry.
|
||||
service.registerView({
|
||||
// @ts-expect-error unregistered view id
|
||||
id: 'vt-ghost',
|
||||
label: 'x',
|
||||
component: PlainView,
|
||||
})
|
||||
// 4. Render side: per-view props narrow — the extended view's density
|
||||
// is not accessible under another id's props type.
|
||||
const renderPlain = (props: ConvViewPropsOf<'vt-plain'>): ReactNode => {
|
||||
// @ts-expect-error density belongs to vt-extended's extension, not vt-plain
|
||||
return props.density === 'compact' ? null : null
|
||||
}
|
||||
void renderPlain
|
||||
// 5. Entry-shape drift: ViewEntry<Id> ties chrome and component to the
|
||||
// SAME id — mixing ids inside one entry fails.
|
||||
const mixed: ViewEntry<'vt-extended'> = {
|
||||
id: 'vt-extended',
|
||||
label: 'x',
|
||||
component: ExtendedView,
|
||||
// @ts-expect-error chrome for vt-plain cannot ride a vt-extended entry
|
||||
chrome: { header: (props: ChromePropsOf<'vt-plain'> & { onlyPlain: true }) => null },
|
||||
}
|
||||
void mixed
|
||||
return null as ReactNode
|
||||
}
|
||||
expect(negatives).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('view-ring full chain (positive dual)', () => {
|
||||
it('registers, lists, and renders through the per-view extension shapes', () => {
|
||||
const ctx = new Context()
|
||||
const service = new ConversationService(ctx)
|
||||
// Registration: extension-typed component + same-id chrome compose cleanly.
|
||||
const dispose = service.registerView({
|
||||
id: 'vt-extended',
|
||||
label: '扩展视图',
|
||||
order: 7,
|
||||
component: ExtendedView,
|
||||
chrome: { footer: ExtendedChrome },
|
||||
})
|
||||
const entry = service.views().find(v => v.id === 'vt-extended')
|
||||
expect(entry?.label).toBe('扩展视图')
|
||||
// Render surface: the listed entry's component accepts the composed props
|
||||
// (base ConvViewProps + the map extension), spelled here as the same type
|
||||
// the runtime hands over.
|
||||
expect(typeof entry?.component).toBe('function')
|
||||
expect(typeof entry?.chrome?.footer).toBe('function')
|
||||
dispose()
|
||||
expect(service.views().some(v => v.id === 'vt-extended')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user