219 lines
9.2 KiB
TypeScript
219 lines
9.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
|
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
|
import {
|
|
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
|
|
} from '@deepseek-ai/dsh-client-test-runtime'
|
|
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import {
|
|
apply, inject, type ComposerBarInjected, type ConversationInjected,
|
|
type ConversationSessionInjected, type ViewTab,
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
|
import { createConversationStore } from '../src/client/stores.ts'
|
|
|
|
usePinnedBrowserLanguages('zh-CN')
|
|
|
|
const ROOT = 'root-1' as SessionId
|
|
|
|
type ConversationInstance = ReturnType<ReturnType<typeof createConversationStore>['create']>
|
|
type ConversationActions = ConversationInstance['actions']
|
|
|
|
function sessionFakeFor() {
|
|
return {
|
|
loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
|
|
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
|
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
|
} satisfies SessionBehaviorOverrides
|
|
}
|
|
|
|
async function bench() {
|
|
const runtime = await SlotTestRuntime.create()
|
|
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
|
const connectWorkspace = vi.fn(async () => ROOT)
|
|
runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never)
|
|
const sessionFake = sessionFakeFor()
|
|
await runtime.sessions.add({
|
|
id: ROOT,
|
|
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
|
|
session: sessionFake,
|
|
}, { current: false })
|
|
const locale = new LocaleRuntime(runtime.ctx)
|
|
runtime.ctx.provide('locale', locale)
|
|
runtime.slots.installLocale(locale)
|
|
await runtime.root.declare({
|
|
'conversation': { kind: 'single', scope: 'session-maybe' },
|
|
}, (_props: { renderSlot?: unknown }) => null)
|
|
|
|
const feature = await runtime.mount({ inject: [...inject], apply })
|
|
runtime.renderRoot()
|
|
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar') =>
|
|
runtime.slots.entries(key)[0]!
|
|
const conversationApi = (id: SessionId) => {
|
|
const entry = entryOf('conversation.session')
|
|
const instance = runtime.storeOf('conversation.session', id) as ConversationInstance
|
|
const injected = (entry.inject as unknown as (
|
|
sessionId: SessionId,
|
|
actions: ConversationActions,
|
|
) => ConversationSessionInjected)(id, instance.actions)
|
|
return { instance, injected }
|
|
}
|
|
const residentApi = (id: SessionId | undefined) => {
|
|
const entry = entryOf('conversation')
|
|
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
|
|
}
|
|
const composerApi = (id: SessionId | undefined) => {
|
|
const entry = entryOf('conversation.composer.bar')
|
|
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
|
|
}
|
|
const inputApi = (id: SessionId) => {
|
|
const input = runtime.ctx.conversation.input.for(runtime.sessions.scope(id)!)
|
|
return { state: input.state, actions: input }
|
|
}
|
|
const viewSource = (id: SessionId): ObservableSnapshot<readonly ViewTab[]> =>
|
|
conversationApi(id).injected.hooks.conversationViews
|
|
return {
|
|
runtime, feature, slots: runtime.slots, entryOf, conversationApi, residentApi, composerApi,
|
|
inputApi, viewSource, sessionFake, connectWorkspace,
|
|
}
|
|
}
|
|
|
|
describe('Conversation inject API', () => {
|
|
it('assembles the target-neutral read face without Session side effects', async () => {
|
|
const b = await bench()
|
|
const { injected } = b.conversationApi(ROOT)
|
|
expect(b.sessionFake.loadOlder).not.toHaveBeenCalled()
|
|
expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror'])
|
|
expect(b.viewSource(ROOT).getSnapshot()).toEqual([])
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('submits through the provided input machine and mirrors accepted draft edits', async () => {
|
|
const b = await bench()
|
|
const { injected } = b.conversationApi(ROOT)
|
|
const { state, actions } = b.inputApi(ROOT)
|
|
actions.setDraft(' ')
|
|
actions.submit()
|
|
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
|
expect(state.getSnapshot().draft).toBe(' ')
|
|
|
|
actions.setDraft('hello')
|
|
actions.submit()
|
|
await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') })
|
|
expect(b.sessionFake.prompt).toHaveBeenCalledWith(
|
|
[{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal),
|
|
)
|
|
|
|
b.sessionFake.prompt.mockResolvedValueOnce({
|
|
ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } },
|
|
})
|
|
actions.setDraft('retry me')
|
|
actions.submit()
|
|
await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) })
|
|
await Promise.resolve()
|
|
expect(state.getSnapshot().draft).toBe('retry me')
|
|
|
|
const mirrored: string[] = []
|
|
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
|
|
actions.setDraft('mirrored text')
|
|
expect(mirrored).toEqual(['mirrored text'])
|
|
unbind()
|
|
expect(b.inputApi(ROOT).state).toBe(state)
|
|
|
|
b.sessionFake.cancel.mockResolvedValueOnce({
|
|
ok: false, error: { code: 'internal', message: 'stop failed', details: {} },
|
|
})
|
|
b.composerApi(ROOT).stop!()
|
|
await vi.waitFor(() => { expect(b.sessionFake.cancel).toHaveBeenCalledOnce() })
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('fails loud for an unknown binding or an unloaded scoped service', async () => {
|
|
const b = await bench()
|
|
const entry = b.entryOf('conversation.composer.bar')
|
|
const injectBar = entry.inject as unknown as (
|
|
sessionId: SessionId | undefined,
|
|
) => ComposerBarInjected
|
|
expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
|
|
|
|
const absent = injectBar(undefined)
|
|
expect(absent.keyboard).toBeUndefined()
|
|
expect(absent.toggleCommandMenu).toBeUndefined()
|
|
expect(absent.stop).toBeUndefined()
|
|
expect(absent.hooks.notices.getSnapshot()).toBeNull()
|
|
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
|
|
expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull()
|
|
|
|
const stop = injectBar(ROOT).stop!
|
|
await b.feature.dispose()
|
|
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('moves a draft only when Workspace navigation changes Session', async () => {
|
|
const b = await bench()
|
|
const resident = b.residentApi(ROOT)
|
|
const { state, actions } = b.inputApi(ROOT)
|
|
actions.setDraft('carry me')
|
|
|
|
b.connectWorkspace.mockResolvedValueOnce(ROOT)
|
|
await resident.selectWorkspace('workspace-1' as WorkspaceId)
|
|
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
|
expect(state.getSnapshot().draft).toBe('carry me')
|
|
|
|
const other = 'other-1' as SessionId
|
|
await b.runtime.sessions.add({ id: other }, { current: false })
|
|
b.connectWorkspace.mockResolvedValueOnce(other)
|
|
await resident.selectWorkspace('workspace-2' as WorkspaceId)
|
|
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] })
|
|
expect(state.getSnapshot().draft).toBe('')
|
|
expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me')
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('supports no-Session navigation and propagates Workspace connection failure', async () => {
|
|
const b = await bench()
|
|
b.connectWorkspace.mockResolvedValueOnce(ROOT)
|
|
await b.residentApi(undefined).selectWorkspace('workspace-0' as WorkspaceId)
|
|
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
|
|
|
const opens = b.runtime.sessions.calls.filter(call => call.method === 'open').length
|
|
b.connectWorkspace.mockRejectedValueOnce(new Error('offline'))
|
|
await expect(b.residentApi(ROOT).selectWorkspace('workspace-4' as WorkspaceId))
|
|
.rejects.toThrow('offline')
|
|
expect(b.runtime.sessions.calls.filter(call => call.method === 'open')).toHaveLength(opens)
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('projects the dynamic View registration ledger', async () => {
|
|
const b = await bench()
|
|
const source = b.viewSource(ROOT)
|
|
const before = source.getSnapshot()
|
|
const listener = vi.fn()
|
|
const unsubscribe = source.subscribe(listener)
|
|
const removeNamed = b.slots.register(
|
|
{ name: 'conversation.view', id: 'trajectory', order: 5, label: 'Trajectory' },
|
|
(() => null) as never,
|
|
)
|
|
await vi.waitFor(() => {
|
|
expect(source.getSnapshot()).toEqual([{ id: 'trajectory', label: 'Trajectory' }])
|
|
})
|
|
expect(listener).toHaveBeenCalledOnce()
|
|
expect(source.getSnapshot()).not.toBe(before)
|
|
|
|
const removeBare = b.slots.register(
|
|
{ name: 'conversation.view', id: 'bare', order: 6 },
|
|
(() => null) as never,
|
|
)
|
|
await vi.waitFor(() => {
|
|
expect(source.getSnapshot().map(view => view.label)).toEqual(['Trajectory', 'bare'])
|
|
})
|
|
removeNamed()
|
|
removeBare()
|
|
unsubscribe()
|
|
await b.runtime.dispose()
|
|
})
|
|
})
|