browser-plugin.client.spec.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. // @vitest-environment jsdom
  2. /**
  3. * ui-goal browser half on a real cordis Context with fake slots/api/
  4. * sessions faces: the plugin registers the GoalBar dock entry at
  5. * conversation.input.dock, the inject face's four verbs read the CAS ref
  6. * from the session's CURRENT projected value at call time (no fence — the
  7. * Remote method's compare-and-set is the guard), a missing projection short-circuits
  8. * to the no-current-goal error without touching the wire, and a Remote failure
  9. * reaches the strip verbatim. Registration disposal rides the
  10. * plugin fiber (HMR safety). The node half and the invariant companion are
  11. * exercised over the same Context.
  12. */
  13. import { Context, Service } from '@deepseek-ai/cordis'
  14. import { describe, expect, it, vi } from 'vitest'
  15. import { cleanup, render } from '@testing-library/react'
  16. import { afterEach } from 'vitest'
  17. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  18. import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
  19. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  20. import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
  21. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  22. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  23. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  24. import type { GoalBarActions } from '../src/client/slots.ts'
  25. import { apply, inject } from '../src/client/index.ts'
  26. import { GoalDock } from '../src/client/GoalBar.tsx'
  27. import { zh } from '../src/client/locales.ts'
  28. import { apply as nodeApply } from '../src/index.ts'
  29. afterEach(cleanup)
  30. const sid = (k: string): SessionId => k as SessionId
  31. function makeProjection(revision = 3): GoalProjection {
  32. return {
  33. goal: {
  34. id: 'g-1' as GoalProjection['goal']['id'],
  35. revision,
  36. objective: 'Ship it',
  37. phase: 'active',
  38. maxGoalRounds: 8,
  39. },
  40. roundsStarted: 1,
  41. createdAt: 10,
  42. updatedAt: 20,
  43. }
  44. }
  45. /** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */
  46. async function bench(options: {
  47. projection?: GoalProjection | null | undefined
  48. failWith?: { code: string; message: string; details: object }
  49. } = {}) {
  50. const ctx = new Context()
  51. const calls: { method: string; args: unknown[] }[] = []
  52. const sessions = {
  53. binding: (id: SessionId) => ({
  54. sessionId: id,
  55. session: { projections: { faceOf: (key: string) => ({
  56. getSnapshot: () => (key === 'goal' ? options.projection : undefined),
  57. subscribe: () => () => {},
  58. }) } },
  59. ctx,
  60. }),
  61. }
  62. ctx.provide('sessions', sessions)
  63. const conversationEvents = new UiConversation(ctx, sessions as never).events
  64. function answer<T>(method: string, value: T) {
  65. return (...args: unknown[]) => {
  66. calls.push({ method, args })
  67. if (options.failWith !== undefined) return Promise.resolve({ ok: false, error: options.failWith })
  68. return Promise.resolve({ ok: true, value })
  69. }
  70. }
  71. const ref = { id: 'g-1', revision: 3 }
  72. const goals = (prefix: string) => ({
  73. edit: answer(`${prefix}/edit`, { ref }),
  74. pause: answer(`${prefix}/pause`, { ref }),
  75. resume: answer(`${prefix}/resume`, { ref }),
  76. clear: answer(`${prefix}/clear`, ref),
  77. })
  78. let activeGoals: ReturnType<typeof goals> | undefined = goals('goals')
  79. class RemoteService extends Service {
  80. constructor(serviceCtx: Context) {
  81. super(serviceCtx, 'remote')
  82. }
  83. }
  84. new RemoteService(ctx)
  85. ctx.provide('remote.goals', {
  86. get edit() { return activeGoals?.edit },
  87. get pause() { return activeGoals?.pause },
  88. get resume() { return activeGoals?.resume },
  89. get clear() { return activeGoals?.clear },
  90. })
  91. await ctx.plugin(SlotRegistry).await()
  92. ctx.slots.register({
  93. name: 'root', children: {
  94. 'conversation.input.dock': { kind: 'list', scope: 'session' },
  95. 'conversation.chat.node': { kind: 'keyed', scope: 'session' },
  96. },
  97. } as never, (() => null) as never)
  98. ctx.provide('locale', new LocaleRuntime(ctx))
  99. const fiber = ctx.plugin({ inject: [...inject], apply })
  100. return {
  101. ctx,
  102. fiber,
  103. calls,
  104. definitions: () => conversationEvents.entries(),
  105. remountGoals: () => { activeGoals = goals('remounted-goals') },
  106. unmountGoals: () => { activeGoals = undefined },
  107. entry: () => {
  108. const entry = ctx.slots.entries('conversation.input.dock')[0]
  109. if (entry === undefined) return undefined
  110. return {
  111. ...entry.options,
  112. locale: entry.locale,
  113. inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined,
  114. }
  115. },
  116. chatEntry: () => ctx.slots.entries('conversation.chat.node')[0],
  117. }
  118. }
  119. describe('ui-goal browser plugin', () => {
  120. it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => {
  121. const b = await bench()
  122. await b.fiber.await()
  123. expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
  124. expect(b.entry()?.inject).toBeTypeOf('function')
  125. expect(b.definitions().map(definition => definition.kind)).toEqual(['goal-command-input'])
  126. expect(b.chatEntry()?.options).toMatchObject({ key: 'command-input' })
  127. expect(b.chatEntry()?.locale).toBe('goal')
  128. })
  129. it('verbs read the CAS ref from the current projected value at call time', async () => {
  130. const b = await bench({ projection: makeProjection(5) })
  131. await b.fiber.await()
  132. const verbs = b.entry()!.inject!(sid('s1'))
  133. // The strip forwards the Remote value verbatim; `answered` is the fake's
  134. // reply, unrelated to the CAS ref the call carries.
  135. const answered = { id: 'g-1', revision: 3 }
  136. expect(await verbs.onEdit('New objective')).toEqual({ ok: true, value: { ref: answered } })
  137. expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: answered } })
  138. expect(await verbs.onResume()).toEqual({ ok: true, value: { ref: answered } })
  139. expect(await verbs.onClear()).toEqual({ ok: true, value: answered })
  140. expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear'])
  141. const ref = { id: 'g-1', revision: 5 }
  142. expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }])
  143. expect(b.calls[1]?.args).toEqual(['s1', ref])
  144. expect(b.calls[2]?.args).toEqual(['s1', ref])
  145. expect(b.calls[3]?.args).toEqual(['s1', ref])
  146. })
  147. it('verbs read a remounted Remote namespace at action time', async () => {
  148. const b = await bench({ projection: makeProjection() })
  149. await b.fiber.await()
  150. const verbs = b.entry()!.inject!(sid('s1'))
  151. b.remountGoals()
  152. expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: { id: 'g-1', revision: 3 } } })
  153. expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
  154. })
  155. it('rejects every verb once the Remote namespace is gone', async () => {
  156. const b = await bench({ projection: makeProjection() })
  157. await b.fiber.await()
  158. const verbs = b.entry()!.inject!(sid('s1'))
  159. b.unmountGoals()
  160. // A missing namespace is an assembly fault, not a call outcome: this plugin
  161. // declares remote.goals in `inject`, so cordis disposes the dock entry along
  162. // with the namespace. Only a React closure that outlived that disposal can
  163. // reach these verbs, so no consumer-side guard renders it as an error.
  164. for (const verb of [() => verbs.onEdit('x'), () => verbs.onPause(), () => verbs.onResume(), () => verbs.onClear()]) {
  165. await expect(verb()).rejects.toThrow(TypeError)
  166. }
  167. expect(b.calls).toHaveLength(0)
  168. })
  169. it('a null or absent projection short-circuits every verb without touching the wire', async () => {
  170. for (const projection of [null, undefined]) {
  171. const b = await bench({ projection })
  172. await b.fiber.await()
  173. const verbs = b.entry()!.inject!(sid('s1'))
  174. for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
  175. expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} } })
  176. }
  177. expect(b.calls).toHaveLength(0)
  178. }
  179. })
  180. it('forwards a Remote failure to the strip verbatim', async () => {
  181. const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision', details: {} } })
  182. await b.fiber.await()
  183. const verbs = b.entry()!.inject!(sid('s1'))
  184. expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision', details: {} } })
  185. })
  186. it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
  187. const b = await bench()
  188. await b.fiber.await()
  189. expect(b.entry()).toBeDefined()
  190. expect(b.chatEntry()).toBeDefined()
  191. expect(b.definitions()).toHaveLength(1)
  192. await b.fiber.dispose()
  193. expect(b.entry()).toBeUndefined()
  194. expect(b.chatEntry()).toBeUndefined()
  195. expect(b.definitions()).toHaveLength(0)
  196. })
  197. })
  198. describe('GoalDock adapter', () => {
  199. it('renders the projected goal snapshot and nothing for absent/null', () => {
  200. const projection = makeProjection()
  201. const useProjection = vi.fn(() => projection)
  202. const actions: GoalBarActions = {
  203. onEdit: () => Promise.resolve({ ok: true, value: undefined }),
  204. onPause: () => Promise.resolve({ ok: true, value: undefined }),
  205. onResume: () => Promise.resolve({ ok: true, value: undefined }),
  206. onClear: () => Promise.resolve({ ok: true, value: undefined }),
  207. }
  208. const t = makeTranslate(zh, commonZh)
  209. const dockProps = (up: () => GoalProjection | null | undefined) =>
  210. ({ useProjection: up, ...actions, t }) as unknown as Parameters<typeof GoalDock>[0]
  211. const shown = render(<GoalDock {...dockProps(useProjection)} />)
  212. expect(shown.getByText('Ship it')).toBeTruthy()
  213. cleanup()
  214. const empty = render(<GoalDock {...dockProps(() => null)} />)
  215. expect(empty.container.firstChild).toBeNull()
  216. cleanup()
  217. const absent = render(<GoalDock {...dockProps(() => undefined)} />)
  218. expect(absent.container.firstChild).toBeNull()
  219. })
  220. })
  221. describe('ui-goal node half', () => {
  222. // The invariant companion is mounted by the vitest-wide invariant host on
  223. // every Context this suite creates; its registration is covered there.
  224. it('the node apply is an inert loader seat', () => {
  225. expect(() => { nodeApply() }).not.toThrow()
  226. })
  227. })