Merge remote-tracking branch 'origin/master' into worktree/web-session-titles
# Conflicts: # .agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/selection-survival.spec.ts # packages/client/ui-conversation/tests/skeleton-branches.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-layout/tests/service.spec.ts # packages/client/ui-sidebar/tests/apply.spec.tsx # packages/client/ui-sidebar/tests/store.spec.ts # packages/client/ui-trajectory/tests/views.spec.tsx # packages/client/web/src/app.tsx # packages/client/web/tests/boot.spec.tsx # packages/host/runtime/README.md # packages/host/runtime/tests/host-runtime.spec.ts
This commit is contained in:
@@ -1,26 +1,34 @@
|
||||
// @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.
|
||||
// apply inject factories exercised end to end against the terminal thin
|
||||
// shape: the conversation surface (views triple, send choreography incl.
|
||||
// optimistic clear + failure restore THROUGH the declared store actions,
|
||||
// openDetails = select action + layout orchestration, sessions.open
|
||||
// navigation), the injectless-but-closeDetails details surface, and the
|
||||
// one-callback empty surface. Complements chat-apply.spec.tsx (registration)
|
||||
// and selection-survival.spec.ts (store axis). History opening is NOT an
|
||||
// inject concern anymore — the runtime sessions service opens on watch
|
||||
// (sessions-service.spec.ts owns that behavior).
|
||||
|
||||
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 { cleanup } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
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'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
|
||||
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
type ChatActions = ChatInstance['actions']
|
||||
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
@@ -35,28 +43,18 @@ const SCOPE_TAG: symbol = (() => {
|
||||
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 slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
})
|
||||
const snap = snapshotBase()
|
||||
current: ROOT,
|
||||
} as SessionListState)
|
||||
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 } }>>(
|
||||
@@ -64,7 +62,6 @@ async function bench() {
|
||||
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)
|
||||
@@ -77,200 +74,189 @@ async function bench() {
|
||||
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),
|
||||
cell: () => undefined,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
}
|
||||
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(),
|
||||
}
|
||||
const layoutFake = { 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' })
|
||||
// The AppFrame role: the three conversation-package slots must be declared
|
||||
// by a live entry before apply can contribute into them (the stand-in
|
||||
// consumes renderSlot to satisfy the declare-means-render check).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
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,
|
||||
// Reach the render-side entry view (inject + store handle) the way the
|
||||
// renderer does: through the host face.
|
||||
let host: SlotRendererHost | undefined
|
||||
slots.install({ renderRoot: (h) => { host = h; return null } })
|
||||
slots.renderSlot('root', {})
|
||||
const hostFace = host!
|
||||
const entryOf = (key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
|
||||
/** Resolve store instance + call the inject the way the outlet would. */
|
||||
const conversationSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation')
|
||||
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => {
|
||||
const entries = slots.entries(key)
|
||||
return entries[0]! as { options: { inject: (b: unknown) => Record<string, unknown> } }
|
||||
/** Same resolution for the chat entry riding the view ring. */
|
||||
const chatViewSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.view')
|
||||
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
return { ctx, slots, binding, sessionFake, sessionsFake, layoutFake, mint, entryOf }
|
||||
return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
|
||||
}
|
||||
|
||||
describe('conversation slot inject surface', () => {
|
||||
it('assembles the full surface and pulls history through the watch signal', async () => {
|
||||
it('assembles the thin surface side-effect-free, navigates via sessions.open', 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)
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
// Assembly has no session side effects: opening the event window belongs
|
||||
// to the runtime watch path, not the inject factory.
|
||||
expect(b.sessionFake.open).not.toHaveBeenCalled()
|
||||
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)
|
||||
injected.open(ROOT)
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
// loadOlder moved to the chat view entry's face (the ring rider).
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('composer send trims, optimistically clears, and restores on failure; stop swallows rejection', async () => {
|
||||
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', 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')
|
||||
const { instance, injected } = b.conversationSurface(ROOT)
|
||||
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
|
||||
instance.actions.setDraft(' ')
|
||||
injected.send(' ', 'queue')
|
||||
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
||||
expect(instance.store.getSnapshot().draft).toBe(' ')
|
||||
// Success: cleared and stays cleared.
|
||||
injected.composer.setDraft('hello')
|
||||
injected.composer.send('queue')
|
||||
expect(scoped.drafts.getSnapshot()).toBe('')
|
||||
instance.actions.setDraft('hello')
|
||||
injected.send('hello', 'queue')
|
||||
expect(instance.store.getSnapshot().draft).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')
|
||||
instance.actions.setDraft('retry me')
|
||||
injected.send('retry me', 'queue')
|
||||
await vi.waitFor(() => {
|
||||
expect(scoped.drafts.getSnapshot()).toBe('retry me')
|
||||
expect(instance.store.getSnapshot().draft).toBe('retry me')
|
||||
})
|
||||
// Failure with new typing: no clobber.
|
||||
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
injected.composer.send('queue')
|
||||
injected.composer.setDraft('typed during flight')
|
||||
injected.send('retry me', 'queue')
|
||||
instance.actions.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(scoped.drafts.getSnapshot()).toBe('typed during flight')
|
||||
expect(instance.store.getSnapshot().draft).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()
|
||||
injected.stop()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('view actions forward: openDetails writes selection through the scoped service, loadOlder hits the session', async () => {
|
||||
it('inject fails loud when the session resolves no scope or the scope lacks the service', 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' })
|
||||
const entry = b.entryOf('conversation')
|
||||
const instance = b.hostFace.storeOf(entry, ROOT) as ChatInstance
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected
|
||||
// Unknown session: sessions.scope answers nothing.
|
||||
;(b.sessionsFake.scope as unknown) = () => undefined
|
||||
expect(() => injectFn(ROOT, instance.actions)).toThrow(/resolved no scope/)
|
||||
// A scope minted outside the service tree: no conversation service on it.
|
||||
const foreign = new Context()
|
||||
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
|
||||
expect(() => injectFn(ROOT, instance.actions)).toThrow(/unavailable through the session scope/)
|
||||
})
|
||||
|
||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||
const b = await bench()
|
||||
const { instance, injected } = b.chatViewSurface(ROOT)
|
||||
injected.openDetails({ turnSeq: 2, callId: 'c1' })
|
||||
expect(instance.store.getSnapshot().selection).toEqual({ 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)
|
||||
// The chat view shares the conversation entry's store instance: selection
|
||||
// writes land where the skeleton and details read.
|
||||
const conv = b.conversationSurface(ROOT)
|
||||
expect(conv.instance).toBe(instance)
|
||||
})
|
||||
|
||||
it('renderView mounts chrome header/footer around the view body', async () => {
|
||||
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', 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 { displayTitle: 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 { injected } = b.conversationSurface(ROOT)
|
||||
const before = injected.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)
|
||||
const unsub = injected.views.subscribe(listener)
|
||||
// A second ring rider (what ui-trajectory does in production).
|
||||
const off = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never)
|
||||
await Promise.resolve() // ledger notifications batch per microtask
|
||||
expect(listener).toHaveBeenCalled()
|
||||
expect(injected3.views.version()).toBeGreaterThan(beforeVersion)
|
||||
offExtra()
|
||||
expect(injected.views.version()).toBeGreaterThan(before)
|
||||
expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
|
||||
// Label falls back to the id when a rider declares none.
|
||||
const off2 = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
|
||||
expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
|
||||
off()
|
||||
off2()
|
||||
unsub()
|
||||
})
|
||||
})
|
||||
|
||||
describe('details and empty inject surfaces', () => {
|
||||
it('details surface wires selection and closeDetails', async () => {
|
||||
it('details injects the one layout callback; selection rides the shared store instead', 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()
|
||||
const entry = b.entryOf('details')
|
||||
const injected = (entry.inject as unknown as () => DetailsInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['closeDetails'])
|
||||
injected.closeDetails()
|
||||
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
|
||||
// The shared handle: details resolves the SAME instance conversation writes.
|
||||
const conv = b.hostFace.storeOf(b.entryOf('conversation'), ROOT)
|
||||
const details = b.hostFace.storeOf(entry, ROOT)
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('empty surface derives the deduped cwd set with a per-state cache and starts sessions', async () => {
|
||||
it('empty injects the startSession chain only (no store, cwds derive in-component)', 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' })
|
||||
const entry = b.entryOf('conversation.empty')
|
||||
expect(entry.store).toBeUndefined()
|
||||
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession'])
|
||||
await injected.startSession({ text: 'go', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
|
||||
})
|
||||
|
||||
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
|
||||
const b = await bench()
|
||||
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
|
||||
// Tear the service's own fiber (registry keyed by the class): the slot
|
||||
// entries survive, so the gesture-time read hits the loud branch.
|
||||
b.ctx.registry.delete(ConversationService)
|
||||
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
|
||||
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// @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.
|
||||
// apply wiring: the conversation service provided, the chat view registered
|
||||
// as the first 'conversation.view' ring entry declaring the keyed toolview
|
||||
// hole, the three slot registrations land against a root entry's children
|
||||
// declarations (the AppFrame role), the shared store handle rides all session
|
||||
// entries, and the bash sample mounts through the load-order seam as a keyed
|
||||
// entry. Full-chain rendering belongs to the machinery spec
|
||||
// (chat-toolview-slot.spec.tsx) and 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 { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
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'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
const CHILD = 'child-1' as SessionId
|
||||
@@ -29,77 +29,101 @@ async function bench() {
|
||||
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 },
|
||||
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
|
||||
},
|
||||
})
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
manager: { get: vi.fn() },
|
||||
ancestry: () => [],
|
||||
scope: () => undefined,
|
||||
cell: () => undefined,
|
||||
create: vi.fn(),
|
||||
open: 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('layout', { 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.
|
||||
// Declared by ui-layout's root entry in production; a stand-in root
|
||||
// occupant declares them here so the contributions land (it consumes
|
||||
// renderSlot to satisfy the declare-means-render check).
|
||||
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' })
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
return { ctx, fiber, slots }
|
||||
}
|
||||
|
||||
/** First stored entry for a key (inject/store live directly on StoredEntry). */
|
||||
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') {
|
||||
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
|
||||
}
|
||||
|
||||
describe('apply wiring', () => {
|
||||
it('provides conversation and toolviews services', async () => {
|
||||
it('provides the conversation service', 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 () => {
|
||||
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', 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()
|
||||
const entries = b.slots.entries('conversation.view')
|
||||
expect(entries.map((e) => e.options.id)).toEqual(['chat'])
|
||||
expect(entries[0]?.options.label).toBe('Chat')
|
||||
expect(entries[0]?.options.order).toBe(0)
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
// the ledger with the contract's kind/scope.
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
|
||||
})
|
||||
|
||||
it('occupies conversation/details/conversation.empty with inject factories', async () => {
|
||||
it('occupies the three slots + the ring; session entries share one store handle, empty declares none', 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')
|
||||
}
|
||||
const conversation = renderEntryOf(b.slots, 'conversation')
|
||||
const chatView = renderEntryOf(b.slots, 'conversation.view')
|
||||
const details = renderEntryOf(b.slots, 'details')
|
||||
const empty = renderEntryOf(b.slots, 'conversation.empty')
|
||||
expect(conversation?.inject).toBeTypeOf('function')
|
||||
expect(chatView?.inject).toBeTypeOf('function')
|
||||
expect(details?.inject).toBeTypeOf('function')
|
||||
expect(empty?.inject).toBeTypeOf('function')
|
||||
// The shared handle: one apply-built store value on ALL session entries.
|
||||
expect(conversation?.store).toBeDefined()
|
||||
expect(details?.store).toBe(conversation?.store)
|
||||
expect(chatView?.store).toBe(conversation?.store)
|
||||
// The empty slot is storeless (local state + useSessions derivation).
|
||||
expect(empty?.store).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bash samples resolve differentially: scoped row for sub-sessions, global for roots', async () => {
|
||||
it('mounts the bash sample as a keyed entry through the load-order seam', 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)
|
||||
// The sample plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map((e) => e.options.key)).toEqual(['bash'])
|
||||
})
|
||||
|
||||
it('plugin fiber disposal collects every registration (unload cascade)', async () => {
|
||||
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
await b.fiber.dispose()
|
||||
expect(b.slots.entries('conversation')).toHaveLength(0)
|
||||
// The declared ring collapses with its declaring entry, and the chat
|
||||
// entry's keyed hole (with the sample's registration) collapses with it.
|
||||
expect(b.slots.entries('conversation.view')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,20 @@
|
||||
// @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.
|
||||
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
|
||||
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
|
||||
// machinery specs since the tool ring dissolved into renderSlot.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } 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 { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
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'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.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(
|
||||
@@ -85,72 +64,8 @@ describe('small branch tails', () => {
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
// @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.
|
||||
// StatsLine (rendered inside the chat view body): totals derivation + the RFC
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: the
|
||||
// canonical sub-agent differential decided INSIDE the component off the
|
||||
// standard useSessions kit (no registry predicates — tool ring dissolved).
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } 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'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -77,8 +75,8 @@ describe('deriveStats', () => {
|
||||
})
|
||||
|
||||
describe('StatsLine', () => {
|
||||
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
|
||||
return { sessionId: SID, useSession: bindSnapshotSelector(source) as unknown as UseSession }
|
||||
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
|
||||
return { useSession: bindSnapshotSelector(source) }
|
||||
}
|
||||
|
||||
it('renders the joined stats row and hides with zero steps', () => {
|
||||
@@ -95,7 +93,7 @@ describe('StatsLine', () => {
|
||||
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) {
|
||||
function Counting(p: StatsLineProps) {
|
||||
renders += 1
|
||||
return <StatsLine {...p} />
|
||||
}
|
||||
@@ -109,70 +107,79 @@ describe('StatsLine', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('bash toolview samples', () => {
|
||||
describe('bash sample row', () => {
|
||||
const ROOT = 'root-1' as SessionId
|
||||
const CHILD = 'child-1' as SessionId
|
||||
|
||||
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} />,
|
||||
)
|
||||
/** Real list-store engine: the family fixture the in-component parentId branch reads. */
|
||||
function listStore() {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
}
|
||||
|
||||
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)
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
store?: ReturnType<typeof listStore>
|
||||
openDetails?: () => void
|
||||
}): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
openDetails: over?.openDetails ?? vi.fn(),
|
||||
sessionId,
|
||||
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
|
||||
const scoped = render(<BashRow {...rowProps(CHILD)} />)
|
||||
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
const plain = outlet(registry, SID)
|
||||
expect(scoped.getByText('scoped')).toBeTruthy()
|
||||
const plain = render(<BashRow {...rowProps(ROOT)} />)
|
||||
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('a session outside the list renders the global arm (no parent known)', () => {
|
||||
const view = render(<BashRow {...rowProps('gone' as SessionId)} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
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', displayTitle: 'r', running: false, updatedAt: 0 },
|
||||
[child]: { id: child, title: 'c', displayTitle: 'c', parentId: root, running: false, updatedAt: 0 },
|
||||
},
|
||||
}),
|
||||
it('a live parentId write flips the row to the scoped variant (store subscription)', () => {
|
||||
const store = listStore()
|
||||
const orphan = 'late-child' as SessionId
|
||||
store.update((d) => {
|
||||
d.ids.push(orphan)
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
|
||||
})
|
||||
expect(scope(child)).toBe(true)
|
||||
expect(scope(root)).toBe(false)
|
||||
expect(scope('gone' as SessionId)).toBe(false)
|
||||
const view = render(<BashRow {...rowProps(orphan, { store })} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
act(() => {
|
||||
store.update((d) => { d.byId[orphan]!.parentId = ROOT })
|
||||
})
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
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()
|
||||
it('summarizes the command and hands clicks to openDetails on both arms', () => {
|
||||
const openGlobal = vi.fn()
|
||||
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
|
||||
// Two renders share document.body: query inside each container.
|
||||
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
|
||||
expect(globalRow.textContent).toContain('Build')
|
||||
fireEvent.click(globalRow)
|
||||
expect(openGlobal).toHaveBeenCalledTimes(1)
|
||||
const openScoped = vi.fn()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
|
||||
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
|
||||
expect(scopedRow.textContent).toContain('Build')
|
||||
fireEvent.click(scopedRow)
|
||||
expect(openScoped).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
94
packages/client/ui-conversation/tests/chat-store.spec.ts
Normal file
94
packages/client/ui-conversation/tests/chat-store.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* createChatStore unit account (slot terminal design §4): the declared
|
||||
* actions write set, persist round-trip through the scope-suffixed key, and
|
||||
* factory purity (every create() is an independent instance; the factory
|
||||
* itself holds no singleton state).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
const KEY = 'dsh.conversation.chat'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('createChatStore', () => {
|
||||
it('init shape: empty selection/draft/view', () => {
|
||||
const store = createChatStore().create()
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
})
|
||||
|
||||
it('actions cover the declared write set', () => {
|
||||
const store = createChatStore().create()
|
||||
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
|
||||
expect(store.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
|
||||
store.actions.select(null)
|
||||
expect(store.store.getSnapshot().selection).toBeNull()
|
||||
|
||||
store.actions.setDraft('hello')
|
||||
expect(store.store.getSnapshot().draft).toBe('hello')
|
||||
store.actions.clearDraft()
|
||||
expect(store.store.getSnapshot().draft).toBe('')
|
||||
|
||||
store.actions.setView('chat')
|
||||
expect(store.store.getSnapshot().view).toBe('chat')
|
||||
})
|
||||
|
||||
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
|
||||
const store = createChatStore().create()
|
||||
// Rollback path: draft was cleared by send, nothing typed since.
|
||||
store.actions.restoreDraft('failed text')
|
||||
expect(store.store.getSnapshot().draft).toBe('failed text')
|
||||
// The user typed something new before the failure landed: keep theirs.
|
||||
store.actions.setDraft('newer input')
|
||||
store.actions.restoreDraft('stale text')
|
||||
expect(store.store.getSnapshot().draft).toBe('newer input')
|
||||
})
|
||||
|
||||
it('persists per scope key and rehydrates a fresh instance', () => {
|
||||
const handle = createChatStore()
|
||||
const s1 = handle.create('sess-1')
|
||||
s1.actions.setDraft('draft for one')
|
||||
s1.actions.select({ turnSeq: 1 })
|
||||
|
||||
// Scope-suffixed key: each session persists separately.
|
||||
expect(localStorage.getItem(`${KEY}.sess-1`)).not.toBeNull()
|
||||
expect(localStorage.getItem(`${KEY}.sess-2`)).toBeNull()
|
||||
|
||||
// A rebuilt instance under the same scope key rehydrates the state.
|
||||
const again = createChatStore().create('sess-1')
|
||||
expect(again.store.getSnapshot().draft).toBe('draft for one')
|
||||
expect(again.store.getSnapshot().selection).toEqual({ turnSeq: 1 })
|
||||
|
||||
// A sibling scope starts clean.
|
||||
const other = createChatStore().create('sess-2')
|
||||
expect(other.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('clearPersisted removes the scope entry (session-death cleanup hook)', () => {
|
||||
const store = createChatStore().create('sess-9')
|
||||
store.actions.setDraft('doomed')
|
||||
expect(localStorage.getItem(`${KEY}.sess-9`)).not.toBeNull()
|
||||
store.clearPersisted()
|
||||
expect(localStorage.getItem(`${KEY}.sess-9`)).toBeNull()
|
||||
})
|
||||
|
||||
it('every create() is an independent instance; the factory holds no singleton', () => {
|
||||
const handle = createChatStore()
|
||||
const a = handle.create()
|
||||
const b = handle.create()
|
||||
a.actions.setDraft('only in a')
|
||||
expect(b.store.getSnapshot().draft).toBe('')
|
||||
// Two factory calls likewise share no LIVE state (identity is per handle
|
||||
// VALUE, not per module — the sharing contract lives in the framework's
|
||||
// handle x scope-key resolution, not in module state). Persistence is the
|
||||
// one sanctioned cross-instance channel: clear it so this assertion sees
|
||||
// memory identity, not rehydration (covered by the persist case above).
|
||||
localStorage.clear()
|
||||
const c = createChatStore().create()
|
||||
expect(c.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,11 @@ 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 { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
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'
|
||||
import type { ToolRowOwnerProps } 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"}',
|
||||
@@ -28,6 +28,8 @@ describe('tool-call-model', () => {
|
||||
expect(classifyTool('web_fetch')).toBe('read')
|
||||
expect(classifyTool('web_search')).toBe('search')
|
||||
expect(classifyTool('grep')).toBe('search')
|
||||
expect(classifyTool('write')).toBe('write')
|
||||
expect(classifyTool('edit')).toBe('edit')
|
||||
expect(classifyTool('todo_write')).toBe('others')
|
||||
})
|
||||
|
||||
@@ -48,6 +50,8 @@ describe('tool-call-model', () => {
|
||||
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')
|
||||
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
|
||||
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/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}')
|
||||
@@ -114,12 +118,28 @@ describe('ToolRow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ThinkRow', () => {
|
||||
it('expands from either Think or the reasoning summary', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
const row = view.getByRole('button')
|
||||
|
||||
fireEvent.click(view.getByText('Inspect the session'))
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText(/Check persistence/)).toBeTruthy()
|
||||
|
||||
fireEvent.click(view.getByText('Think'))
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName, block, openDetails: vi.fn(),
|
||||
})
|
||||
|
||||
it('renders the classified variant row from the frozen slice', () => {
|
||||
@@ -138,10 +158,36 @@ describe('GenericToolCard', () => {
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click reaches actions.openDetails', () => {
|
||||
it('renders edit with its dedicated title, icon variant, and path summary', () => {
|
||||
const view = render(
|
||||
<GenericToolCard {...props('edit', running({
|
||||
name: 'edit',
|
||||
argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}',
|
||||
}))} />,
|
||||
)
|
||||
expect(view.getByText('Edit')).toBeTruthy()
|
||||
expect(view.getByText('src/x.ts')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull()
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders write with its dedicated title, icon variant, and path summary', () => {
|
||||
const view = render(
|
||||
<GenericToolCard {...props('write', running({
|
||||
name: 'write',
|
||||
argsRaw: '{"file_path":"src/x.ts","content":"hello"}',
|
||||
}))} />,
|
||||
)
|
||||
expect(view.getByText('Write')).toBeTruthy()
|
||||
expect(view.getByText('src/x.ts')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull()
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click reaches openDetails', () => {
|
||||
const p = props('bash', result())
|
||||
const view = render(<GenericToolCard {...p} />)
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(p.actions.openDetails).toHaveBeenCalledTimes(1)
|
||||
expect(p.openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment jsdom
|
||||
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
|
||||
// cordis Context + SlotsService ledger + the web-react renderer + this
|
||||
// package's own apply — no outlet twins. Proves the keyed
|
||||
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
|
||||
// entryKey (the bash sample lands through its plugin), unregistered tools
|
||||
// fall back to GenericToolCard at the render site, live registration/unload
|
||||
// flips rows in place, duplicate keys fail loud, the inject channel feeds
|
||||
// (sessionId) => I into row components, and a registrant's
|
||||
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
|
||||
// semantics until the service (and with it the hole declaration) is present.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear between cases.
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, callId,
|
||||
call: { name, argsRaw: args },
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
function snapshotWith(nodes: ToolResultNode[]): 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
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
|
||||
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
|
||||
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
|
||||
* fakes at the service seams only (external boundaries), the package apply on
|
||||
* its own fiber, and the test AppFrame occupying 'root'.
|
||||
*/
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
} as SessionListState)
|
||||
// Identity-stable cell: the renderer caches hooks per source and inject
|
||||
// results per cell, both by object identity.
|
||||
const cell = { sessionId: SID, session }
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
manager: { get: () => ({ loadOlder: vi.fn() }) },
|
||||
scope: () => ({ get: () => scoped }),
|
||||
cell: (id: string) => (id === SID ? cell : undefined),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
slots.install(createSlotRenderer())
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, list, layout }
|
||||
}
|
||||
|
||||
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
|
||||
function mountApp(slots: SlotsService) {
|
||||
return render(<>{slots.renderSlot('root', {})}</>)
|
||||
}
|
||||
|
||||
describe('keyed toolview hole through the real machinery', () => {
|
||||
it('dispatches registered rows by entryKey and unregistered tools to the GenericToolCard fallback', async () => {
|
||||
const b = await bench([
|
||||
toolResult(3, 'c1', 'bash'),
|
||||
toolResult(4, 'c2', 'mystery', '{"n":1}'),
|
||||
])
|
||||
const view = mountApp(b.slots)
|
||||
// bash: the sample plugin's keyed registration took the row (root
|
||||
// session → global arm, decided inside the component off useSessions).
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('Build')).toBeTruthy()
|
||||
// mystery: no registration under that key → render-site fallback.
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'bash')])
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('Build').click()
|
||||
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
|
||||
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
|
||||
const view = mountApp(b.slots)
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
let dispose = (): void => {}
|
||||
await act(async () => {
|
||||
dispose = b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'mystery' },
|
||||
() => <div data-testid="mystery-row" />)
|
||||
})
|
||||
// Per-key version tick: the row flipped without a remount of the view.
|
||||
expect(view.getByTestId('mystery-row')).toBeTruthy()
|
||||
expect(view.queryByText('Tool call')).toBeNull()
|
||||
await act(async () => { dispose() })
|
||||
expect(view.queryByTestId('mystery-row')).toBeNull()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a duplicate key registration fails loud at load', async () => {
|
||||
const b = await bench([])
|
||||
// The bash sample already holds the 'bash' key (later-wins retired with
|
||||
// the ring — the keyed ledger throws instead).
|
||||
expect(() => b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'bash' },
|
||||
() => null,
|
||||
)).toThrow(/key "bash"/)
|
||||
})
|
||||
|
||||
it('the inject channel feeds (sessionId) => I into the row component', async () => {
|
||||
const b = await bench([toolResult(3, 'c3', 'probe', '{"x":1}')])
|
||||
const poked: string[] = []
|
||||
b.slots.register({
|
||||
name: 'conversation.chat.toolview',
|
||||
key: 'probe',
|
||||
// Two-way business face: data derived from the session id out, a
|
||||
// callback closing over it back in — the askuser-pattern inject shape.
|
||||
inject: (sessionId: SessionId) => ({
|
||||
mark: `for:${sessionId}`,
|
||||
poke: () => { poked.push(sessionId) },
|
||||
}),
|
||||
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
|
||||
<button data-testid="probe-row" onClick={poke}>{mark}</button>
|
||||
))
|
||||
const view = mountApp(b.slots)
|
||||
const row = view.getByTestId('probe-row')
|
||||
expect(row.textContent).toBe(`for:${SID}`)
|
||||
row.click()
|
||||
expect(poked).toEqual([SID])
|
||||
})
|
||||
})
|
||||
|
||||
describe('registrant load-order seam', () => {
|
||||
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined } as SessionListState),
|
||||
manager: { get: vi.fn() },
|
||||
scope: () => undefined,
|
||||
cell: () => undefined,
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
// semantics hold it — apply must not run while 'conversation' is absent.
|
||||
// (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
|
||||
// fiber's isConstructor branch.)
|
||||
let applyRuns = 0
|
||||
const registrantApply = (registrantCtx: Context): void => {
|
||||
applyRuns += 1
|
||||
registrantCtx.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
|
||||
}
|
||||
const late = ctx.plugin({
|
||||
name: 'late-registrant',
|
||||
inject: ['slots', 'conversation'],
|
||||
apply: registrantApply,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(applyRuns).toBe(0)
|
||||
|
||||
// Mounting the package resolves the seam: service present ⟹ the chat
|
||||
// entry (and its hole declaration) is already on the ledger, so the
|
||||
// suspended registrant lands without an undeclared-slot throw.
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await late.await()
|
||||
expect(applyRuns).toBe(1)
|
||||
expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
|
||||
.toEqual(expect.arrayContaining(['bash', 'late']))
|
||||
})
|
||||
})
|
||||
@@ -3,20 +3,25 @@
|
||||
// toolview dispatch and selection handoff — driven through a scripted
|
||||
// ObservableSnapshot fake, no wire.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, 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,
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, 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 { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
// Keyless create() persists under the bare declared key; clear between cases
|
||||
// so one harness's selection cannot rehydrate into the next.
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
@@ -62,39 +67,41 @@ 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 }
|
||||
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
},
|
||||
function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const { set, source } = makeSource(init)
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
|
||||
// every tool lands on GenericToolCard); keyed dispatch to registered rows
|
||||
// is the slot machinery's behavior, covered by its own specs.
|
||||
const chat = createChatStore().create()
|
||||
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
|
||||
// SessionProvider seat arrives with the session-scope child declaration;
|
||||
// ChatView never invokes it (render-prop pass-through stub).
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
const props: ChatViewSlotProps = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(source),
|
||||
useSessions: emptySessions(),
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
SessionProvider: SessionProviderStub,
|
||||
openDetails,
|
||||
loadOlder,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, loadOlder, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -179,11 +186,13 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
|
||||
})
|
||||
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||
let rowRenders = 0
|
||||
h.registry.register('bash', () => {
|
||||
h.props.renderSlot = (((_key: string, _owner: object) => {
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
})
|
||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('counting-row')).toBeTruthy()
|
||||
const afterMount = rowRenders
|
||||
@@ -221,21 +230,19 @@ describe('ChatView', () => {
|
||||
expect(view.getByText('cmd-r1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a scoped toolview registration takes over rendering for its session only', () => {
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
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()
|
||||
const calls: { key: string; entryKey?: string }[] = []
|
||||
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
|
||||
return opts?.fallback ?? null
|
||||
}) as unknown as ChatViewSlotProps['renderSlot'])
|
||||
render(<h.ChatView {...h.props} />)
|
||||
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
|
||||
// name, and the fallback (GenericToolCard) renders on an empty ledger.
|
||||
// (Registered-row takeover and live unload are slot machinery behavior,
|
||||
// owned by the slot system's own specs.)
|
||||
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
|
||||
})
|
||||
|
||||
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
// @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.
|
||||
// PendingCard question arm, bash sample error pill, 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 { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionListState, 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 type { ToolRowOwnerProps, ToolRowProps } 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)
|
||||
|
||||
@@ -67,11 +65,8 @@ describe('tails', () => {
|
||||
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 props: ToolRowOwnerProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
|
||||
}
|
||||
const view = render(<GenericToolCard {...props} />)
|
||||
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
|
||||
@@ -79,49 +74,25 @@ describe('tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('BashRow shows the failed pill on error results', () => {
|
||||
it('BashRow shows the failed pill on error results (root session arm)', () => {
|
||||
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,
|
||||
}
|
||||
// Root session (no parentId): the global arm renders, error pill visible.
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', running: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
const props = {
|
||||
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps
|
||||
const view = render(<BashRow {...props} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
// @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.
|
||||
// Final branch tails for the coverage gate, terminal slot form:
|
||||
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
|
||||
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
|
||||
// retired with the mechanism — derivation lives in EmptyState now, covered
|
||||
// by the skeleton specs.)
|
||||
|
||||
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 { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
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 { createChatStore } from '../src/client/stores.ts'
|
||||
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)
|
||||
|
||||
@@ -31,53 +29,6 @@ function snapshotBase(): ConversationSnapshot {
|
||||
} 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', displayTitle: 'a', cwd: '/proj', running: false, updatedAt: 1 },
|
||||
['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', displayTitle: 'b', cwd: '', running: false, updatedAt: 1 },
|
||||
['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', displayTitle: '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(
|
||||
@@ -101,7 +52,7 @@ describe('render branch tails', () => {
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
|
||||
)
|
||||
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
|
||||
})
|
||||
@@ -114,27 +65,23 @@ describe('render branch tails', () => {
|
||||
})
|
||||
|
||||
it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => {
|
||||
const SEL: SelectionTarget = { turnSeq: 1, callId: 'ghost' }
|
||||
localStorage.clear()
|
||||
const snap = snapshotBase()
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snapshotBase(), subscribe: () => () => {} }) as unknown as UseSession}
|
||||
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
|
||||
actions={{ closeDetails: vi.fn() }}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.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()
|
||||
})
|
||||
})
|
||||
|
||||
26
packages/client/ui-conversation/tests/hook.ts
Normal file
26
packages/client/ui-conversation/tests/hook.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Test-local selector-hook binder: the engine carries no hook since the store
|
||||
* migration (runtime is React-free); the renderer binds in production, specs
|
||||
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
|
||||
* with-selector uSES shim as production, so selector-level render economics —
|
||||
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
|
||||
* in Profiler-count specs). Source-relative import: the package dependency
|
||||
* edge to web-react is gone (store migration §7); tests reach the sibling
|
||||
* package the same way they reach their own src internals.
|
||||
*/
|
||||
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
|
||||
|
||||
/** Minimal observable source (engine stores and scripted fakes both satisfy it). */
|
||||
export interface HookSource<T> {
|
||||
getSnapshot(): T
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a selector hook over a snapshot source.
|
||||
* @param src - the source.
|
||||
* @returns a SnapshotSelectorHook-shaped hook.
|
||||
*/
|
||||
export function hookOf<T>(src: HookSource<T>) {
|
||||
return bindSnapshotSelector<T>(src)
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
// @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.
|
||||
* Selection survival across the store seat (terminal design §4): the chat
|
||||
* store now carries what the per-scope selection account used to — this pins
|
||||
* the same behavior contract in the new mechanism. Drives the REAL
|
||||
* SlotsService store axis with the shared createChatStore handle (the exact
|
||||
* apply.ts shape: one handle, two session-slot registrations): same session's
|
||||
* two slots resolve one instance (conversation writes, details reads);
|
||||
* sessions are isolated; a session's death buries its instance AND its
|
||||
* persisted draft; a list refresh does not touch instance identity.
|
||||
*/
|
||||
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'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
// The runtime package's programmable fake lives in its tests; import through
|
||||
// the src path (same pattern the runtime specs use — test-support material).
|
||||
@@ -22,15 +25,31 @@ interface Bench {
|
||||
ctx: Context
|
||||
api: FakeApiClient
|
||||
sessions: SessionsService
|
||||
conversation: ConversationService
|
||||
slots: SlotsService
|
||||
chat: ReturnType<typeof createChatStore>
|
||||
}
|
||||
|
||||
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 }
|
||||
// Service self-registers as ctx 'slots' (cordis Service constructor).
|
||||
const slots = new SlotsService(ctx)
|
||||
const chat = createChatStore()
|
||||
// The apply.ts shape: one shared handle across both session-slot
|
||||
// registrations. 'conversation'/'details' must first exist in the ledger —
|
||||
// register a root occupant declaring them (the AppFrame role; the stand-in
|
||||
// consumes renderSlot to satisfy the declare-means-render check).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
slots.register({ name: 'conversation', store: chat }, () => null)
|
||||
slots.register({ name: 'details', store: chat }, () => null)
|
||||
return { ctx, api, sessions, slots, chat }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
@@ -48,8 +67,63 @@ function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[])
|
||||
}) as never)
|
||||
}
|
||||
|
||||
describe('selection survives list refreshes (M1a)', () => {
|
||||
it('create → select → display-title-upgrading refresh keeps scope, binding, store and value', async () => {
|
||||
/** Resolve the store instance the renderer would hand a slot's component for a session. */
|
||||
function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: SessionId) {
|
||||
const host = renderHost(b)
|
||||
const entry = host.entriesOf(slot)[0]!
|
||||
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
}
|
||||
|
||||
/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
|
||||
function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
|
||||
const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
|
||||
if (captured._host === undefined) {
|
||||
b.slots.install({
|
||||
renderRoot: (host) => {
|
||||
captured._host = host
|
||||
return null
|
||||
},
|
||||
})
|
||||
b.slots.renderSlot('root', {})
|
||||
}
|
||||
return captured._host!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('selection survives on the store seat', () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const conv = storeFor(b, 'conversation', sid('s1'))
|
||||
const details = storeFor(b, 'details', sid('s1'))
|
||||
conv.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
// Identity, not just value: the shared handle resolves one instance per scope key.
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const one = storeFor(b, 'conversation', sid('s1'))
|
||||
const two = storeFor(b, 'conversation', sid('s2'))
|
||||
expect(two).not.toBe(one)
|
||||
one.actions.select({ turnSeq: 1, callId: 'a' })
|
||||
two.actions.select({ turnSeq: 9, callId: 'z' })
|
||||
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
})
|
||||
|
||||
it('a display-title-upgrading list refresh keeps instance identity and the selection 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') }))
|
||||
@@ -58,11 +132,9 @@ describe('selection survives list refreshes (M1a)', () => {
|
||||
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
|
||||
|
||||
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' })
|
||||
const store = storeFor(b, 'conversation', id)
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
store.actions.setDraft('half-typed')
|
||||
|
||||
// The late list refresh lands (host knows the cwd → better fallback label).
|
||||
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
|
||||
@@ -71,53 +143,40 @@ describe('selection survives list refreshes (M1a)', () => {
|
||||
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
|
||||
|
||||
// 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
|
||||
const after = storeFor(b, 'conversation', id)
|
||||
expect(after).toBe(store)
|
||||
expect(after.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
expect(after.store.getSnapshot().draft).toBe('half-typed')
|
||||
})
|
||||
|
||||
it('reconnect (handleConnected: refreshList + resync) keeps the selection account', async () => {
|
||||
it('session death buries the instance and its persisted draft', async () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }])
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
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' })
|
||||
// Mint the scope (store prune rides the scope-teardown axis: no scope,
|
||||
// no teardown — the real page always resolves the binding to render).
|
||||
b.sessions.binding(sid('s1'))
|
||||
const doomed = storeFor(b, 'conversation', sid('s1'))
|
||||
doomed.actions.setDraft('to be buried')
|
||||
doomed.actions.select({ turnSeq: 1 })
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
|
||||
|
||||
// Reconnect generation: display-title fallback 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' }])
|
||||
// Watch elsewhere so s1's scope teardown is not deferred, then remove it.
|
||||
b.sessions.binding(sid('s2'))
|
||||
feed(b, [{ id: 's2' }])
|
||||
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()
|
||||
// Persisted residue is gone with the session...
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
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' })
|
||||
const reborn = storeFor(b, 'conversation', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @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.
|
||||
* ConversationService orchestration half after the store-seat slimming:
|
||||
* scope-addressed send/cancel (result folding, root throw), the startSession
|
||||
* chain (create → sessions.open → scoped send), and the service-unavailable
|
||||
* loud failures. Selection/draft state left this service for the declared
|
||||
* chat store (chat-store.spec.ts / selection-survival.spec.ts); the view
|
||||
* registry left for the 'conversation.view' slot (views-type-chain.spec.tsx).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -13,7 +15,7 @@ import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/cli
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/** Recover the module-private scope tag through the public seam (same probe as service-stores.spec). */
|
||||
/** Recover the module-private scope tag through the public seam (same probe as apply-inject.spec). */
|
||||
const SCOPE_TAG: symbol = (() => {
|
||||
const recorded: (string | symbol)[] = []
|
||||
const spy = new Proxy(new Context(), {
|
||||
@@ -33,7 +35,7 @@ interface SessionDouble {
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
||||
async function bench(opts?: { sessions?: boolean }) {
|
||||
const ctx = new Context()
|
||||
const sessionDoubles = new Map<SessionId, SessionDouble>()
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
@@ -47,6 +49,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
||||
return scoped
|
||||
}
|
||||
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
|
||||
const openMock = vi.fn()
|
||||
const sessionsFake = {
|
||||
manager: {
|
||||
get: (id: SessionId) => {
|
||||
@@ -62,16 +65,16 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
|
||||
},
|
||||
},
|
||||
create: createMock,
|
||||
open: openMock,
|
||||
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) })
|
||||
// Class-plugin mount — the same form apply.ts uses in production.
|
||||
const fiber = ctx.plugin(ConversationService)
|
||||
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 }
|
||||
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, openMock }
|
||||
}
|
||||
|
||||
describe('send / cancel', () => {
|
||||
@@ -109,22 +112,12 @@ describe('send / cancel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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 () => {
|
||||
it('creates, navigates through sessions.open, 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.openMock).toHaveBeenCalledWith(sid('new-1'))
|
||||
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
|
||||
[{ type: 'text', text: 'first' }], 'queue')
|
||||
})
|
||||
@@ -148,12 +141,6 @@ describe('service-unavailable loud failures', () => {
|
||||
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.
|
||||
@@ -164,28 +151,3 @@ describe('service-unavailable loud failures', () => {
|
||||
.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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
// @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([])
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,22 @@
|
||||
// @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.
|
||||
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
|
||||
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
|
||||
// result blocks / error-only results over the shared store, EmptyState
|
||||
// failure surface and custom-directory swap with in-component cwd derivation.
|
||||
|
||||
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'
|
||||
import { hookOf } from './hook.ts'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -32,37 +38,54 @@ function sessionSource(over?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
}
|
||||
|
||||
const summary = (id: string, title: string): SessionSummary =>
|
||||
({ id: id as SessionId, title: `durable ${title}`, displayTitle: title, running: false, updatedAt: 1 })
|
||||
/** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */
|
||||
function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
|
||||
const store = createSnapshotStore<SessionListState>({
|
||||
ids: rows.map(r => r.id as SessionId),
|
||||
byId: Object.fromEntries(rows.map(r => [r.id, {
|
||||
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
|
||||
}])),
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
return hookOf(store)
|
||||
}
|
||||
|
||||
describe('ConversationRoot branches', () => {
|
||||
const chatEntry: ViewEntry = {
|
||||
id: 'chat', label: 'Chat', component: () => null,
|
||||
} as unknown as ViewEntry
|
||||
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
|
||||
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
|
||||
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
|
||||
function rootProps(over?: {
|
||||
ancestry?: readonly SessionSummary[]
|
||||
rows?: { id: string; title: string; parentId?: string }[]
|
||||
snapshot?: Partial<ConversationSnapshot>
|
||||
}) {
|
||||
const open = vi.fn()
|
||||
const chat = createChatStore().create()
|
||||
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" />}
|
||||
useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={listHook(over?.rows ?? [])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={stubRenderSlot}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
open={open}
|
||||
/>,
|
||||
)
|
||||
return { view, open }
|
||||
return { view, open, chat }
|
||||
}
|
||||
|
||||
it('renders the ancestry breadcrumb with separators and navigates on ancestor click', () => {
|
||||
it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => {
|
||||
const { view, open } = rootProps({
|
||||
ancestry: [summary('root-1', 'Workspace'), summary('s1', 'Current')],
|
||||
rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }],
|
||||
})
|
||||
expect(view.getByText('Workspace')).toBeTruthy()
|
||||
expect(view.getByText('/')).toBeTruthy()
|
||||
@@ -73,6 +96,14 @@ describe('ConversationRoot branches', () => {
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a broken parent link stops the ancestry walk at the known chain', () => {
|
||||
const { view } = rootProps({
|
||||
rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }],
|
||||
})
|
||||
// The walk keeps s1 itself and stops where the parent is unknown.
|
||||
expect(view.getByText('Orphan')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the raw session id without ancestry and counts user turns', () => {
|
||||
const { view } = rootProps({
|
||||
snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] },
|
||||
@@ -88,31 +119,41 @@ describe('ConversationRoot branches', () => {
|
||||
expect(view.getByText(/停止失败:halt(internal)/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an unknown active view id falls back to the first registered view', () => {
|
||||
it('an unknown stored view id falls back to the first registered view', () => {
|
||||
const { chat } = rootProps({})
|
||||
cleanup()
|
||||
chat.actions.setView('gone')
|
||||
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}`} />}
|
||||
useSession={hookOf(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={listHook([])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={stubRenderSlot}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
open={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByTestId('body-chat')).toBeTruthy()
|
||||
expect(view.getByTestId('view-body')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel branches', () => {
|
||||
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession}
|
||||
useSelection={bindSnapshotSelector({ getSnapshot: () => selection, subscribe: () => () => {} })}
|
||||
actions={{ closeDetails: vi.fn() }}
|
||||
useSession={hookOf(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={listHook([])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
@@ -139,13 +180,16 @@ describe('DetailsPanel branches', () => {
|
||||
return () => subs.delete(fn)
|
||||
},
|
||||
}
|
||||
const SEL: SelectionTarget = { turnSeq: 1, callId: 'c9' }
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ 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() }}
|
||||
useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={listHook([])}
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
@@ -189,18 +233,10 @@ describe('DetailsPanel branches', () => {
|
||||
})
|
||||
|
||||
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 }}
|
||||
/>,
|
||||
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'first task' } })
|
||||
@@ -212,10 +248,7 @@ describe('EmptyState branches', () => {
|
||||
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 }}
|
||||
/>,
|
||||
<EmptyState useSessions={listHook([])} startSession={startSession} />,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'go' } })
|
||||
@@ -223,15 +256,20 @@ describe('EmptyState branches', () => {
|
||||
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 () => {
|
||||
it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => {
|
||||
const startSession = vi.fn(() => Promise.resolve())
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
|
||||
actions={{ startSession }}
|
||||
useSessions={listHook([
|
||||
{ id: 'a', title: 'a', cwd: '/proj' },
|
||||
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
|
||||
])}
|
||||
startSession={startSession}
|
||||
/>,
|
||||
)
|
||||
const select = view.container.querySelector('select')!
|
||||
expect([...(select as HTMLSelectElement).options].map(o => o.value))
|
||||
.toEqual(['', '/proj', '::new-directory'])
|
||||
fireEvent.change(select, { target: { value: '/proj' } })
|
||||
expect((select as HTMLSelectElement).value).toBe('/proj')
|
||||
fireEvent.change(select, { target: { value: '::new-directory' } })
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
// @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.
|
||||
* Skeleton acceptance over the four-share props form: empty-state transition
|
||||
* (same InputBar component in hero position, startSession submit, in-component
|
||||
* cwd derivation), ConversationRoot view switching through the store's view
|
||||
* field, DetailsPanel selection through the shared store. Components stay
|
||||
* pure — the framework shares are stubbed (useSession/useSessions), the store
|
||||
* share is a REAL createChatStore().create() instance (same construction path
|
||||
* as production), injected callbacks are spies.
|
||||
*/
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
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'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
/** Minimal conversation snapshot slice the skeleton reads. */
|
||||
interface FakeSnapshot {
|
||||
@@ -34,17 +42,41 @@ 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 }
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
|
||||
function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
|
||||
const store = createSnapshotStore<SessionListState>({
|
||||
ids: rows.map(r => sid(r.id)),
|
||||
byId: Object.fromEntries(rows.map(r => [r.id, {
|
||||
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
|
||||
}])),
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
return { store, useSessions: bindSnapshotSelector(store) }
|
||||
}
|
||||
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
|
||||
|
||||
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'])
|
||||
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'a', title: 'a', cwd: '/w/app' },
|
||||
{ id: 'b', title: 'b', cwd: '/w/lib' },
|
||||
{ id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes
|
||||
])
|
||||
let reject!: (e: Error) => void
|
||||
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
||||
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession }} />)
|
||||
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '项目目录' }), { target: { value: '/w/app' } })
|
||||
const select = screen.getByRole('combobox', { name: '项目目录' })
|
||||
expect([...(select as HTMLSelectElement).options].map(o => o.value))
|
||||
.toEqual(['', '/w/app', '/w/lib', '::new-directory'])
|
||||
fireEvent.change(select, { 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' })
|
||||
@@ -57,8 +89,8 @@ describe('EmptyState', () => {
|
||||
})
|
||||
|
||||
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() }} />)
|
||||
const { useSessions } = fakeSessions([])
|
||||
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
|
||||
const custom = screen.getByPlaceholderText(/目录路径/)
|
||||
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
|
||||
@@ -67,90 +99,108 @@ describe('EmptyState', () => {
|
||||
})
|
||||
|
||||
describe('ConversationRoot', () => {
|
||||
function bench(views: ViewEntry[], active?: string) {
|
||||
function bench(tabs: ViewTab[], activeView?: 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 { useSessions } = fakeSessions([
|
||||
{ id: 'root', title: 'proj' },
|
||||
{ id: 's1', title: 'child', parentId: 'root' },
|
||||
])
|
||||
const chat = createChatStore().create()
|
||||
if (activeView !== undefined) chat.actions.setView(activeView)
|
||||
const send = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const ancestry: SessionSummary[] = [
|
||||
{ id: sid('root'), title: 'proj', displayTitle: 'proj', running: false, updatedAt: 1 },
|
||||
{ id: sid('s1'), title: 'child', displayTitle: 'child', running: false, updatedAt: 1, parentId: sid('root') },
|
||||
]
|
||||
const rendered: string[] = []
|
||||
const open = vi.fn()
|
||||
// The renderSlot share as the outlet would bake it: renders a marker for
|
||||
// the ring key carrying the active-id filter (a Mock cannot satisfy the
|
||||
// generic method type directly — cast once at the prop seam).
|
||||
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
|
||||
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
|
||||
))
|
||||
const ui = render(
|
||||
<ConversationRoot
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useAncestry={() => ancestry}
|
||||
useSessions={useSessions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{
|
||||
list: () => views,
|
||||
list: () => tabs,
|
||||
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}`} /> }}
|
||||
send={send}
|
||||
stop={stop}
|
||||
open={open}
|
||||
/>)
|
||||
return { ui, openView, open, rendered, send, drafts }
|
||||
return { ui, chat, send, stop, open, renderSlot }
|
||||
}
|
||||
|
||||
const comp = (() => null) as unknown as FC<never>
|
||||
const view = (id: string, label: string): ViewEntry =>
|
||||
({ id, label, component: comp }) as unknown as ViewEntry
|
||||
const tab = (id: string, label: string): ViewTab => ({ id, label })
|
||||
|
||||
it('renders breadcrumb chain, meta turns, and the active view (default chat)', () => {
|
||||
const { rendered, open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
|
||||
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
|
||||
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('child')).toBeTruthy()
|
||||
expect(screen.getByText(/2 turns/)).toBeTruthy()
|
||||
expect(rendered).toEqual(['chat'])
|
||||
expect(screen.getByTestId('view-chat')).toBeTruthy()
|
||||
// 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')])
|
||||
it('switches views through the store view field and falls back on unknown ids', () => {
|
||||
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(openView).toHaveBeenCalledWith('trajectory')
|
||||
expect(chat.store.getSnapshot().view).toBe('trajectory')
|
||||
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
|
||||
cleanup()
|
||||
// A stale persisted id (its view plugin unloaded) falls to the first view.
|
||||
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
|
||||
expect(screen.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the tab strip with a single view and wires the composer send', () => {
|
||||
const { send } = bench([view('chat', 'Chat')])
|
||||
it('renders the active view through the declared ring slot with the only filter', () => {
|
||||
const { renderSlot } = bench([tab('chat', 'Chat')])
|
||||
// No owner share: views take everything from the standard kit (contract).
|
||||
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
|
||||
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
|
||||
})
|
||||
|
||||
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
|
||||
const { chat, send } = bench([tab('chat', 'Chat')])
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
const box = screen.getByPlaceholderText(/输入消息/)
|
||||
fireEvent.change(box, { target: { value: 'hi' } })
|
||||
// Typing goes through actions.setDraft into the shared store.
|
||||
expect(chat.store.getSnapshot().draft).toBe('hi')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledWith('queue')
|
||||
expect(send).toHaveBeenCalledWith('hi', 'queue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel', () => {
|
||||
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
|
||||
const { useSession } = fakeSession(snapshot)
|
||||
const selectionStore = createSnapshotStore<SelectionTarget | null>(selection)
|
||||
const { useSessions } = fakeSessions([])
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const closeDetails = vi.fn()
|
||||
render(
|
||||
<DetailsPanel
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useSelection={selectionStore.useSelector}
|
||||
actions={{ closeDetails }}
|
||||
useSessions={useSessions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
/>)
|
||||
return { closeDetails, selectionStore }
|
||||
return { closeDetails, chat }
|
||||
}
|
||||
|
||||
it('renders the selected call args and result; close fires the layout-linked action', () => {
|
||||
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
|
||||
const { closeDetails } = benchDetails({
|
||||
nodes: [{
|
||||
kind: 'tool-result', callId: 'c1',
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
@@ -1,100 +1,122 @@
|
||||
// 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.
|
||||
// View-ring + toolview-hole type-chain samples, slot form: both are declared
|
||||
// slots, so the register→inject→render chain and its compile-time locks are
|
||||
// the slot system's (ui-slots/tests/type-chain.spec.tsx owns the generic
|
||||
// duals). This spec pins the package-specific surface: the SlotMap rows
|
||||
// (kind/scope/owner), list- and keyed-kind registration shapes, the ChatView
|
||||
// and tool-row composed-props contracts, and the runtime dual — a real
|
||||
// SlotsService ledger driving registration/order/disposal the way
|
||||
// ConversationRoot's tab projection consumes it.
|
||||
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'
|
||||
import type { ReactNode } from 'react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatViewSlotProps, ConvViewProps, ToolRowProps } from '../src/client/contract/slots.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)', () => {
|
||||
describe('view-ring type 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
|
||||
const negatives = (slots: SlotsService) => {
|
||||
// 1. List-kind registration requires the id shape field.
|
||||
// @ts-expect-error missing `id` on a list-slot registration
|
||||
slots.register({ name: 'conversation.view', order: 1 }, (_p: ConvViewProps) => null)
|
||||
// 2. A keyed-kind shape field is rejected on the list slot.
|
||||
slots.register(
|
||||
// @ts-expect-error `key` belongs to keyed slots, not the list ring
|
||||
{ name: 'conversation.view', id: 'x', key: 'k' },
|
||||
(_p: ConvViewProps) => null)
|
||||
// 3. Component props must stay within the composed contract: an
|
||||
// undeclared member cannot be required.
|
||||
// @ts-expect-error component demands a prop no share supplies
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'y' },
|
||||
(_p: ConvViewProps & { phantom: number }) => null)
|
||||
// 4. Views receive no renderSlot — the ring's entries declare no children.
|
||||
const renderless = (props: ConvViewProps): ReactNode => {
|
||||
// @ts-expect-error views receive no renderSlot — no sub-slot delegation
|
||||
void props.renderSlot
|
||||
return 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 renderless
|
||||
// 5. The chat entry's face is its own: openDetails does not exist on the
|
||||
// base view props (store-less riders never see it).
|
||||
const baseOnly = (props: ConvViewProps): ReactNode => {
|
||||
// @ts-expect-error openDetails lives on ChatViewSlotProps, not the base
|
||||
void props.openDetails
|
||||
return null
|
||||
}
|
||||
void mixed
|
||||
void baseOnly
|
||||
// 6. ChatViewSlotProps carries the full composition (standard kit +
|
||||
// store + inject face) — a handler with a wrong signature is red.
|
||||
const chatProps = (props: ChatViewSlotProps): ReactNode => {
|
||||
// @ts-expect-error openDetails takes a SelectionTarget, not a string
|
||||
props.openDetails('nope')
|
||||
return null
|
||||
}
|
||||
void chatProps
|
||||
// 7. Keyed hole registration requires the key shape field.
|
||||
// @ts-expect-error missing `key` on a keyed-slot registration
|
||||
slots.register({ name: 'conversation.chat.toolview' }, (_p: ToolRowProps) => null)
|
||||
// 8. A list-kind shape field is rejected on the keyed hole.
|
||||
slots.register(
|
||||
// @ts-expect-error `id`/`order` belong to list slots, not the keyed hole
|
||||
{ name: 'conversation.chat.toolview', key: 'k', order: 1 },
|
||||
(_p: ToolRowProps) => null)
|
||||
// 9. Tool-row components stay within their composed contract: the
|
||||
// owner share + standard kit supply no chat-view members.
|
||||
const overreaching = (props: ToolRowProps): ReactNode => {
|
||||
// @ts-expect-error loadOlder lives on ChatViewSlotProps, not the row contract
|
||||
void props.loadOlder
|
||||
return null
|
||||
}
|
||||
void overreaching
|
||||
// 10. Owner-share drift is red at the row component seam: block is the
|
||||
// call union, not arbitrary payload.
|
||||
const drifted = (props: ToolRowProps): ReactNode => {
|
||||
// @ts-expect-error the block union has no `argsParsed` member
|
||||
void props.block.argsParsed
|
||||
return null
|
||||
}
|
||||
void drifted
|
||||
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', () => {
|
||||
describe('view-ring runtime dual (real ledger)', () => {
|
||||
function bench() {
|
||||
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)
|
||||
const slots = new SlotsService(ctx)
|
||||
// The conversation entry's role: declare the ring (declaring is claiming).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
return { slots }
|
||||
}
|
||||
|
||||
it('registers, orders, projects tabs, and disposes through the slot ledger', () => {
|
||||
const { slots } = bench()
|
||||
const offLate = slots.register(
|
||||
{ name: 'conversation.view', id: 'z-late', order: 20, label: '晚' }, () => null)
|
||||
const offEarly = slots.register(
|
||||
{ name: 'conversation.view', id: 'early', order: 0, label: '早' }, () => null)
|
||||
// Order-sorted ledger, label fallback for a labelless rider.
|
||||
const offBare = slots.register(
|
||||
{ name: 'conversation.view', id: 'bare', order: 10 }, () => null)
|
||||
const tabs = slots.entries('conversation.view')
|
||||
.map(e => ({ id: e.options.id, label: e.options.label ?? e.options.id }))
|
||||
expect(tabs).toEqual([
|
||||
{ id: 'early', label: '早' },
|
||||
{ id: 'bare', label: 'bare' },
|
||||
{ id: 'z-late', label: '晚' },
|
||||
])
|
||||
// Duplicate ids fail loud at load (the ring's uniqueness contract).
|
||||
expect(() => slots.register({ name: 'conversation.view', id: 'early' }, () => null))
|
||||
.toThrow(/already has an entry with id "early"/)
|
||||
offEarly()
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['bare', 'z-late'])
|
||||
offBare()
|
||||
offLate()
|
||||
expect(slots.entries('conversation.view')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user