Files
deepseek-harness/packages/test-support/client-runtime/tests/helpers.client.spec.tsx
T
Yichen Jiang 9be99a0e28 feat(web): upload composer images when they are added
An ordinary Session's composer now uploads each image draft through the
background upload queue as soon as it is added. The raw upload route stages
the body as a prompt image when the request names its media type: the Host
reads at most the per-image byte limit, then validates, normalizes, and
stores the image as prompt admission would. The prompt cites the receipt in
a file part; prompt admission resolves it to the stored image, applies the
image-input check, and counts stored images toward maxImagesPerMessage.

Send waits for every image receipt, and a failed image upload turns its
thumbnail into a retry control. Commands and subagent continuations keep
encoding images inline.
2026-10-21 13:50:19 +08:00

226 lines
9.0 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react'
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client'
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { MainPanelId, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
import {
allowAllAccess,
bindSnapshotSelector,
chatSnapshot,
conversationSnapshot,
SlotTestRuntime,
usePinnedBrowserLanguages,
} from '../src/index.ts'
const originalLanguages = [...navigator.languages]
const originalLanguage = navigator.language
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'trt.panel-info': { kind: 'keyed'; scope: 'root'; owner: { label: string } }
}
}
usePinnedBrowserLanguages('zh-CN', 'en-US')
afterEach(cleanup)
afterAll(() => {
expect(navigator.languages).toEqual(originalLanguages)
expect(navigator.language).toBe(originalLanguage)
})
function entry(seq: number): SessionLiveEventEntry {
return {
type: 'event',
event: {
type: 'fixture/event',
seq,
time: seq,
data: { seq },
ignorable: true,
} as unknown as SessionLiveEventEntry['event'],
}
}
describe('fixture helpers', () => {
it('allows every action on every resource through the hand-built access Hooks', () => {
expect(allowAllAccess.useRemoteAccess('session/list')).toBe(true)
expect(allowAllAccess.useAccess('session/fork')).toBe(true)
expect(allowAllAccess.useSessionAccess('s1' as SessionId, 'session/fork')).toBe(true)
expect(allowAllAccess.useWorkspaceAccess('w1' as WorkspaceId, 'workspace/follow')).toBe(true)
})
it('installs an allow-all remoteAccess service whose sources never change', async () => {
const runtime = await SlotTestRuntime.create()
try {
const source = runtime.ctx.remoteAccess.source('session', 's1', 'session/fork')
expect(source.getSnapshot()).toBe(true)
const unsubscribe = source.subscribe(() => {})
unsubscribe()
expect(source.getSnapshot()).toBe(true)
} finally {
await runtime.dispose()
}
})
it('retracts default root sources without removing their live replacements', async () => {
const runtime = await SlotTestRuntime.create()
const hooks = { workspaces: runtime.workspaces.list, panelInfo: runtime.panelInfo }
let releaseReplacement: (() => void) | undefined
try {
runtime.releaseWorkspaceSource()
runtime.releasePanelInfoSource()
releaseReplacement = runtime.slots.provideRoot({ hooks })
runtime.releaseWorkspaceSource()
runtime.releasePanelInfoSource()
for (const key of ['workspaces', 'panelInfo'] as const) {
expect(() => runtime.slots.provideRoot({ hooks: { [key]: hooks[key] } }))
.toThrow(`duplicate root standard hook '${key}'`)
}
} finally {
try {
releaseReplacement?.()
runtime.releaseWorkspaceSource()
runtime.releasePanelInfoSource()
} finally {
await runtime.dispose()
}
}
})
it('drives panel hooks, retains keyed selection on owner updates, and releases the default source', async () => {
const runtime = await SlotTestRuntime.create()
try {
await runtime.declare({ 'trt.panel-info': { kind: 'keyed', scope: 'root' } })
runtime.slots.register({ name: 'trt.panel-info', key: 'probe' },
({ usePanelInfo, label }: PropsRuntime<'trt.panel-info'>) => (
<span>{label}:{usePanelInfo(info => info.activePanelId) ?? 'conversation'}</span>
))
const view = runtime.renderSlot('trt.panel-info', { label: 'first' }, { entryKey: 'probe' })
expect(view.container.textContent).toBe('first:conversation')
act(() => { runtime.panelInfo.set({ activePanelId: 'custom' as MainPanelId }) })
expect(view.container.textContent).toBe('first:custom')
view.update({ label: 'next' })
expect(view.container.textContent).toBe('next:custom')
const replacement = createSnapshotStore<PanelInfo>({ activePanelId: null })
await act(async () => {
runtime.releasePanelInfoSource()
await runtime.mount({
inject: ['slots'],
apply(ctx) { ctx.slots.provideRoot({ hooks: { panelInfo: replacement } }) },
})
})
expect(view.container.textContent).toBe('next:conversation')
} finally {
await runtime.dispose()
}
})
it('rejects an upload until a suite replaces the default stub', async () => {
const runtime = await SlotTestRuntime.create()
await expect(runtime.fileUpload.upload('fixture-session' as SessionId)).rejects.toThrow('file upload is not stubbed')
await expect(runtime.fileUpload.uploadImage('fixture-session' as SessionId)).rejects.toThrow('image upload is not stubbed')
await runtime.dispose()
})
it('builds independent Conversation and Chat snapshots with optional overrides', () => {
const conversation = conversationSnapshot()
expect(conversation).toEqual(EMPTY_CONVERSATION_SNAPSHOT)
expect(conversation).not.toBe(EMPTY_CONVERSATION_SNAPSHOT)
const activeTargets = new Set(['chat'])
expect(conversationSnapshot({ activeTargets }).activeTargets).toBe(activeTargets)
const chat = chatSnapshot()
expect(chat).toEqual(EMPTY_CHAT_SNAPSHOT)
expect(chat).not.toBe(EMPTY_CHAT_SNAPSHOT)
const order = ['node-1']
expect(chatSnapshot({ order }).order).toBe(order)
})
it('binds an observable snapshot through the production selector hook', () => {
const source = createSnapshotStore({ value: 1 })
const useValue = bindSnapshotSelector(source)
const view = renderHook(() => useValue(snapshot => snapshot.value))
expect(view.result.current).toBe(1)
act(() => { source.update((draft) => { draft.value = 2 }) })
expect(view.result.current).toBe(2)
})
it('pins both browser language fields for the calling suite', () => {
expect(navigator.languages).toEqual(['zh-CN', 'en-US'])
expect(navigator.language).toBe('zh-CN')
})
})
describe('Session fixture lifecycle', () => {
it('initializes and drives complete event windows through replace, prepend, and append', async () => {
const runtime = await SlotTestRuntime.create()
const first = entry(1)
const older = entry(0)
const live = entry(2)
await runtime.sessions.add({ id: 'events', events: [first] })
expect(runtime.sessions.behavior('events').eventSource.getSnapshot()).toMatchObject({
entries: [first],
hasMore: false,
change: { kind: 'replace', entries: [first] },
})
await runtime.sessions.add({ id: 'has-more', hasMore: true })
expect(runtime.sessions.behavior('has-more').eventSource.getSnapshot()).toMatchObject({
entries: [],
hasMore: true,
})
await runtime.sessions.replaceEvents('events', [first])
await runtime.sessions.prependEvents('events', [older])
await runtime.sessions.appendEvent('events', live)
expect(runtime.sessions.behavior('events').eventSource.getSnapshot()).toMatchObject({
entries: [older, first, live],
hasMore: false,
change: { kind: 'append', entries: [live] },
})
await runtime.dispose()
})
it('requires an explicit create stub and records successful create and refresh calls', async () => {
const runtime = await SlotTestRuntime.create()
await expect(runtime.sessions.create()).rejects.toThrow(/create is not stubbed/)
await runtime.sessions.add({ id: 'created' })
const create = vi.fn(() => Promise.resolve('created' as SessionId))
runtime.sessions.stubCreate(create)
await expect(runtime.sessions.create({ cwd: '/workspace' })).resolves.toBe('created')
await expect(runtime.sessions.refresh()).resolves.toBeUndefined()
expect(create).toHaveBeenCalledWith({ cwd: '/workspace' })
expect(runtime.sessions.calls.slice(-2)).toEqual([
{ method: 'create', args: [{ cwd: '/workspace' }] },
{ method: 'refresh', args: [] },
])
await runtime.dispose()
})
it('borrows only retained scopes and disposes their generation with the runtime', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.sessions.add({ id: 'scope-only' })
expect(runtime.sessions.scope('scope-only')).toBeUndefined()
const reference = runtime.sessions.retain('scope-only' as SessionId)
await reference.ready
const scope = runtime.sessions.scope('scope-only')
expect(scope).toBeDefined()
const release = vi.fn()
scope?.effect(() => release, 'fixture scope release')
runtime.releaseWorkspaceSource()
await runtime.dispose()
expect(release).toHaveBeenCalledOnce()
expect(() => reference.binding).toThrow('released')
})
})