apply-inject.spec.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. // @vitest-environment jsdom
  2. // apply inject factories exercised end to end against the terminal thin
  3. // shape: the conversation surface (views triple, send choreography incl.
  4. // optimistic clear + failure restore THROUGH the declared store actions,
  5. // openDetails = select action + layout orchestration, sessions.open
  6. // navigation), and the closeDetails details surface. Complements
  7. // chat-apply.spec.tsx (registration)
  8. // and selection-survival.spec.ts (store axis). History opening is NOT an
  9. // inject concern anymore — the runtime sessions service opens on watch
  10. // (sessions-service.spec.ts owns that behavior).
  11. import { Context } from 'cordis'
  12. import { afterEach, describe, expect, it, vi } from 'vitest'
  13. import { cleanup } from '@testing-library/react'
  14. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  15. import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
  16. import type {
  17. SessionId, SessionListState, WorkspaceListState,
  18. } from '@deepseek-ai/dsh-client-runtime/client'
  19. import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
  20. import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
  21. import type {
  22. ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
  23. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  24. import type { createChatStore } from '../src/client/stores.ts'
  25. afterEach(cleanup)
  26. const ROOT = 'root-1' as SessionId
  27. type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
  28. type ChatActions = ChatInstance['actions']
  29. const SCOPE_TAG: symbol = (() => {
  30. const recorded: (string | symbol)[] = []
  31. const spy = new Proxy(new Context(), {
  32. get(target, prop, receiver) {
  33. recorded.push(prop)
  34. return Reflect.get(target, prop, receiver)
  35. },
  36. })
  37. void scopeOf(spy as Context)
  38. const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
  39. if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
  40. return symbol
  41. })()
  42. async function bench() {
  43. const ctx = new Context()
  44. const slotsFiber = ctx.plugin(SlotsService)
  45. await slotsFiber.await()
  46. const slots = ctx.get('slots') as SlotsService
  47. const listStore = createSnapshotStore<SessionListState>({
  48. ids: [ROOT],
  49. byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } },
  50. current: ROOT,
  51. phase: 'ready',
  52. })
  53. const sessionFake = {
  54. sessionId: ROOT,
  55. open: vi.fn(() => Promise.resolve()),
  56. loadOlder: vi.fn(() => Promise.resolve()),
  57. prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
  58. () => Promise.resolve({ ok: true, value: { accepted: true } })),
  59. cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
  60. () => Promise.resolve({ ok: true, value: { accepted: true } })),
  61. // Observable face (the input machine's queue read face rides it).
  62. getSnapshot: () => ({ queue: [] }),
  63. subscribe: () => () => {},
  64. }
  65. const scopes = new Map<SessionId, Context>()
  66. const mint = (id: SessionId): Context => {
  67. let scoped = scopes.get(id)
  68. if (scoped === undefined) {
  69. scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context
  70. scopes.set(id, scoped)
  71. }
  72. return scoped
  73. }
  74. type TestProvider = {
  75. resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
  76. hooks?: Record<string, unknown>; props?: Record<string, unknown>
  77. }
  78. }
  79. const providers: TestProvider[] = []
  80. const sessionsFake = {
  81. list: listStore,
  82. binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
  83. scope: (id: SessionId) => mint(id),
  84. provideInfo: () => undefined,
  85. maybeProvideInfo: () => ({ hooks: {}, props: {} }),
  86. provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
  87. scopeOf,
  88. sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
  89. open: vi.fn(),
  90. }
  91. ctx.provide('sessions', sessionsFake)
  92. const workspaceStore = createSnapshotStore<WorkspaceListState>({
  93. items: [], state: 'idle', phase: 'ready', error: null,
  94. baselinesReady: true, recentWorkspaceId: undefined,
  95. })
  96. const workspacesFake = {
  97. list: workspaceStore,
  98. connectWorkspace: vi.fn(async () => ROOT),
  99. }
  100. ctx.provide('workspaces', workspacesFake)
  101. const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
  102. ctx.provide('layout', layoutFake)
  103. ctx.provide('locale', { bind: () => (key: string) => key })
  104. // The AppFrame role: the three conversation-package slots must be declared
  105. // by a live entry before apply can contribute into them (the stand-in
  106. // consumes renderSlot to satisfy the declare-means-render check).
  107. slots.register({
  108. name: 'root',
  109. children: {
  110. 'conversation': { kind: 'single', scope: 'session-maybe' },
  111. 'details': { kind: 'single', scope: 'session' },
  112. },
  113. }, (_p: { renderSlot?: unknown }) => null)
  114. const fiber = ctx.plugin({ inject: [...inject], apply })
  115. await fiber.await()
  116. // Reach the render-side entry view (inject + store handle) the way the
  117. // renderer does: through the host face.
  118. let host: SlotRendererHost | undefined
  119. slots.install({ renderRoot: (h) => { host = h; return null } })
  120. slots.renderSlot('root', {})
  121. const hostFace = host!
  122. const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]!
  123. /** Resolve store instance + call the inject the way the outlet would. */
  124. const conversationSurface = (id: SessionId) => {
  125. const entry = entryOf('conversation.session')
  126. const instance = hostFace.storeOf(entry, id) as ChatInstance
  127. const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
  128. id, instance.actions)
  129. return { instance, injected }
  130. }
  131. const residentSurface = (id: SessionId | undefined) => {
  132. const entry = entryOf('conversation')
  133. return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
  134. }
  135. const composerSurface = (id: SessionId | undefined) => {
  136. const entry = entryOf('conversation.composer.bar')
  137. return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
  138. }
  139. /** Same resolution for the chat entry riding the view ring. */
  140. const chatViewSurface = (id: SessionId) => {
  141. const entry = entryOf('conversation.view')
  142. const instance = hostFace.storeOf(entry, id) as ChatInstance
  143. const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
  144. id, instance.actions)
  145. return { instance, injected }
  146. }
  147. /** Materialize the input provide contribution the way the runtime does. */
  148. const inputSurface = (id: SessionId) => {
  149. const contribution = providers[0]!.resolve(sessionsFake.binding(id))
  150. const state = contribution.hooks!['input'] as {
  151. getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
  152. }
  153. const actions = contribution.props!['inputActions'] as {
  154. setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
  155. }
  156. return { state, actions }
  157. }
  158. return {
  159. ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
  160. sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
  161. }
  162. }
  163. describe('conversation slot inject surface', () => {
  164. it('assembles the thin surface side-effect-free', async () => {
  165. const b = await bench()
  166. const { injected } = b.conversationSurface(ROOT)
  167. // Assembly has no session side effects: opening the event window belongs
  168. // to the runtime watch path, not the inject factory.
  169. expect(b.sessionFake.open).not.toHaveBeenCalled()
  170. expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
  171. const chatView = b.chatViewSurface(ROOT)
  172. chatView.injected.loadOlder()
  173. expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
  174. })
  175. it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
  176. const b = await bench()
  177. const { injected } = b.conversationSurface(ROOT)
  178. const { state, actions } = b.inputSurface(ROOT)
  179. // Whitespace-only: the machine treats it as empty — no prompt, draft kept.
  180. actions.setDraft(' ')
  181. actions.submit('queue')
  182. expect(b.sessionFake.prompt).not.toHaveBeenCalled()
  183. expect(state.getSnapshot().draft).toBe(' ')
  184. // Success: cleared and stays cleared.
  185. actions.setDraft('hello')
  186. actions.submit('queue')
  187. expect(state.getSnapshot().draft).toBe('')
  188. await Promise.resolve()
  189. expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
  190. // Failure: restored (draft still empty when the rejection lands).
  191. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
  192. actions.setDraft('retry me')
  193. actions.submit('queue')
  194. await vi.waitFor(() => {
  195. expect(state.getSnapshot().draft).toBe('retry me')
  196. })
  197. // Failure landing after new typing: no clobber (restore fills empty only).
  198. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
  199. actions.submit('queue')
  200. actions.setDraft('typed during flight')
  201. await new Promise(r => setTimeout(r, 0))
  202. expect(state.getSnapshot().draft).toBe('typed during flight')
  203. // The provide contribution is idempotent per session: one shell identity.
  204. expect(b.inputSurface(ROOT).state).toBe(state)
  205. // The draft mirror rides the conversation inject face.
  206. const mirrored: string[] = []
  207. const unbind = injected.bindDraftMirror(text => mirrored.push(text))
  208. actions.setDraft('mirrored text')
  209. expect(mirrored).toEqual(['mirrored text'])
  210. unbind()
  211. // Stop failure is swallowed (promptError owns the surface).
  212. b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
  213. b.composerSurface(ROOT).stop()
  214. await new Promise(r => setTimeout(r, 0))
  215. expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
  216. })
  217. it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
  218. const b = await bench()
  219. const entry = b.entryOf('conversation.composer.bar')
  220. const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
  221. // Unknown session: sessions.scope answers nothing.
  222. ;(b.sessionsFake.scope as unknown) = () => undefined
  223. expect(() => injectFn(ROOT).stop()).toThrow(/resolved no scope/)
  224. // A scope minted outside the service tree: no conversation service on it.
  225. const foreign = new Context()
  226. ;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
  227. expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/)
  228. })
  229. it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
  230. const b = await bench()
  231. const { instance, injected } = b.chatViewSurface(ROOT)
  232. injected.openDetails({ turnSeq: 2, callId: 'c1' })
  233. expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
  234. expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
  235. // The chat view shares the conversation entry's store instance: selection
  236. // writes land where the skeleton and details read.
  237. const conv = b.conversationSurface(ROOT)
  238. expect(conv.instance).toBe(instance)
  239. })
  240. it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
  241. const b = await bench()
  242. const { injected } = b.conversationSurface(ROOT)
  243. const resident = b.residentSurface(ROOT)
  244. injected.open(ROOT)
  245. expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
  246. // Same-session connect (the picked workspace resolves to this session):
  247. // no draft movement, plain re-open.
  248. const { state, actions } = b.inputSurface(ROOT)
  249. actions.setDraft('carry me')
  250. resident.selectWorkspace('workspace-1' as never)
  251. await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
  252. expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
  253. expect(state.getSnapshot().draft).toBe('carry me')
  254. // Cross-session connect: the draft MOVES — the old machine empties, the
  255. // new session's machine receives the text, then navigation lands there.
  256. const OTHER = 'other-1' as SessionId
  257. b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
  258. resident.selectWorkspace('workspace-2' as never)
  259. await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
  260. expect(state.getSnapshot().draft).toBe('')
  261. expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
  262. })
  263. it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
  264. const b = await bench()
  265. const { injected } = b.conversationSurface(ROOT)
  266. const before = injected.views.version()
  267. const listener = vi.fn()
  268. const unsub = injected.views.subscribe(listener)
  269. // A second ring rider (what ui-trajectory does in production).
  270. const off = b.slots.register(
  271. { name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never)
  272. await Promise.resolve() // ledger notifications batch per microtask
  273. expect(listener).toHaveBeenCalled()
  274. expect(injected.views.version()).toBeGreaterThan(before)
  275. expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
  276. // Label falls back to the id when a rider declares none.
  277. const off2 = b.slots.register(
  278. { name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
  279. expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
  280. off()
  281. off2()
  282. unsub()
  283. })
  284. })
  285. describe('details inject surface', () => {
  286. it('details injects the one layout callback; selection rides the shared store instead', async () => {
  287. const b = await bench()
  288. const entry = b.entryOf('details')
  289. const injected = (entry.inject as unknown as () => DetailsInjected)()
  290. expect(Object.keys(injected)).toEqual(['closeDetails'])
  291. injected.closeDetails()
  292. expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
  293. // The shared handle: details resolves the SAME instance conversation writes.
  294. const conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT)
  295. const details = b.hostFace.storeOf(entry, ROOT)
  296. expect(details).toBe(conv)
  297. })
  298. })