browser-plugin.client.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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), and the node half stays inert.
  11. */
  12. import { Context, Service } from '@deepseek-ai/cordis'
  13. import { describe, expect, it, vi } from 'vitest'
  14. import { cleanup, render, screen, waitFor } from '@testing-library/react'
  15. import { afterEach } from 'vitest'
  16. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  17. import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
  18. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  19. import type { GoalActivation, GoalId, GoalProjection, GoalView } from '@deepseek-ai/dsh-goal/client'
  20. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  21. import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  22. import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
  23. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  24. import type { GoalActivationSnapshot, GoalBarActions, GoalBarInjected } 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. const GOAL_ID = 'g-1' as GoalId
  32. function makeProjection(revision = 3): GoalProjection {
  33. return {
  34. goal: {
  35. id: GOAL_ID,
  36. revision,
  37. objective: 'Ship it',
  38. phase: 'active',
  39. maxGoalRounds: 8,
  40. },
  41. roundsStarted: 1,
  42. createdAt: 10,
  43. updatedAt: 20,
  44. }
  45. }
  46. /** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */
  47. async function bench(options: {
  48. projection?: GoalProjection | null | undefined
  49. activation?: GoalActivation
  50. failWith?: RemoteFailure
  51. } = {}) {
  52. const ctx = new Context()
  53. const calls: { method: string; args: unknown[] }[] = []
  54. const sessions = {
  55. binding: (id: SessionId) => id === sid('missing') ? undefined : ({
  56. sessionId: id,
  57. session: {
  58. getSnapshot: () => ({ running: false }),
  59. subscribe: () => () => {},
  60. projections: { faceOf: (key: string) => ({
  61. getSnapshot: () => (key === 'goal' ? options.projection : undefined),
  62. subscribe: () => () => {},
  63. }) },
  64. },
  65. ctx,
  66. }),
  67. }
  68. ctx.provide('sessions', sessions)
  69. const conversationEvents = new UiConversation(ctx, sessions as never).events
  70. function answer<T>(method: string, value: T) {
  71. return (...args: unknown[]) => {
  72. calls.push({ method, args })
  73. if (options.failWith !== undefined) return Promise.resolve({ ok: false, error: options.failWith })
  74. return Promise.resolve({ ok: true, value })
  75. }
  76. }
  77. const ref = { id: 'g-1', revision: 3 }
  78. const goalView = (): GoalView | undefined => {
  79. if (options.projection === null || options.projection === undefined) return undefined
  80. return {
  81. ...options.projection.goal,
  82. roundsStarted: options.projection.roundsStarted,
  83. createdAt: options.projection.createdAt,
  84. updatedAt: options.projection.updatedAt,
  85. activation: options.activation ?? 'armed',
  86. }
  87. }
  88. const goals = (prefix: string) => ({
  89. get: answer(`${prefix}/get`, goalView()),
  90. edit: answer(`${prefix}/edit`, { ref }),
  91. pause: answer(`${prefix}/pause`, { ref }),
  92. resume: answer(`${prefix}/resume`, { ref }),
  93. clear: answer(`${prefix}/clear`, ref),
  94. })
  95. let activeGoals: ReturnType<typeof goals> | undefined = goals('goals')
  96. class RemoteService extends Service {
  97. readonly activationListeners = new Set<(event: {
  98. sessionId: SessionId
  99. goal?: { id: string; revision: number; activation: GoalActivation }
  100. }) => void>()
  101. constructor(serviceCtx: Context) {
  102. super(serviceCtx, 'remote')
  103. }
  104. $on(_event: string, listener: (event: {
  105. sessionId: SessionId
  106. goal?: { id: string; revision: number; activation: GoalActivation }
  107. }) => void): () => void {
  108. this.activationListeners.add(listener)
  109. return () => { this.activationListeners.delete(listener) }
  110. }
  111. emitActivation(
  112. sessionId: SessionId,
  113. goal: { id: string; revision: number; activation: GoalActivation } | undefined,
  114. ): void {
  115. for (const listener of this.activationListeners) {
  116. listener({ sessionId, ...goal === undefined ? {} : { goal } })
  117. }
  118. }
  119. }
  120. const remote = new RemoteService(ctx)
  121. ctx.provide('remote.goals', {
  122. get get() { return activeGoals?.get },
  123. get edit() { return activeGoals?.edit },
  124. get pause() { return activeGoals?.pause },
  125. get resume() { return activeGoals?.resume },
  126. get clear() { return activeGoals?.clear },
  127. })
  128. await ctx.plugin(SlotRegistry).await()
  129. ctx.slots.register({
  130. name: 'root', children: {
  131. 'conversation.input.dock': { kind: 'list', scope: 'session' },
  132. 'conversation.chat.node': { kind: 'keyed', scope: 'session' },
  133. },
  134. } as never, (() => null) as never)
  135. ctx.provide('locale', new LocaleRuntime(ctx))
  136. const fiber = ctx.plugin({ inject: [...inject], apply })
  137. return {
  138. ctx,
  139. fiber,
  140. calls,
  141. emitActivation: remote.emitActivation.bind(remote),
  142. definitions: () => conversationEvents.entries(),
  143. remountGoals: () => { activeGoals = goals('remounted-goals') },
  144. unmountGoals: () => { activeGoals = undefined },
  145. entry: () => {
  146. const entry = ctx.slots.entries('conversation.input.dock')[0]
  147. if (entry === undefined) return undefined
  148. return {
  149. ...entry.options,
  150. locale: entry.locale,
  151. inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarInjected) | undefined,
  152. }
  153. },
  154. chatEntry: () => ctx.slots.entries('conversation.chat.node')[0],
  155. }
  156. }
  157. describe('ui-goal browser plugin', () => {
  158. it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => {
  159. const b = await bench()
  160. await b.fiber.await()
  161. expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
  162. expect(b.entry()?.inject).toBeTypeOf('function')
  163. expect(() => b.entry()!.inject!(sid('missing'))).toThrow(/unavailable/)
  164. expect(b.definitions().map(definition => definition.kind)).toEqual(['goal-command-input'])
  165. expect(b.chatEntry()?.options).toMatchObject({ key: 'command-input' })
  166. expect(b.chatEntry()?.locale).toBe('goal')
  167. })
  168. it('verbs read the CAS ref from the current projected value at call time', async () => {
  169. const b = await bench({ projection: makeProjection(5) })
  170. await b.fiber.await()
  171. const verbs = b.entry()!.inject!(sid('s1'))
  172. // The strip forwards the Remote value verbatim; `answered` is the fake's
  173. // reply, unrelated to the CAS ref the call carries.
  174. const answered = { id: 'g-1', revision: 3 }
  175. expect(await verbs.onEdit('New objective')).toEqual({ ok: true, value: { ref: answered } })
  176. expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: answered } })
  177. expect(await verbs.onResume()).toEqual({ ok: true, value: { ref: answered } })
  178. expect(await verbs.onClear()).toEqual({ ok: true, value: answered })
  179. expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear'])
  180. const ref = { id: 'g-1', revision: 5 }
  181. expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }])
  182. expect(b.calls[1]?.args).toEqual(['s1', ref])
  183. expect(b.calls[2]?.args).toEqual(['s1', ref])
  184. expect(b.calls[3]?.args).toEqual(['s1', ref])
  185. })
  186. it('verbs read a remounted Remote namespace at action time', async () => {
  187. const b = await bench({ projection: makeProjection() })
  188. await b.fiber.await()
  189. const verbs = b.entry()!.inject!(sid('s1'))
  190. b.remountGoals()
  191. expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: { id: 'g-1', revision: 3 } } })
  192. expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
  193. })
  194. it('binds the activation hook and forwards only this session activation events', async () => {
  195. const b = await bench({ projection: makeProjection(), activation: 'disarmed' })
  196. await b.fiber.await()
  197. const injected = b.entry()!.inject!(sid('s1'))
  198. const source = injected.hooks.goalActivation
  199. const seen: unknown[] = []
  200. const dispose = source.subscribe(() => { seen.push(source.getSnapshot()) })
  201. await waitFor(() => {
  202. expect(source.getSnapshot()).toMatchObject({ id: 'g-1', revision: 3, activation: 'disarmed' })
  203. })
  204. expect(b.calls.at(-1)).toMatchObject({ method: 'goals/get', args: ['s1'] })
  205. b.emitActivation(sid('s2'), { id: 'g-1', revision: 3, activation: 'armed' })
  206. expect(source.getSnapshot().activation).toBe('disarmed')
  207. b.emitActivation(sid('s1'), { id: 'g-1', revision: 3, activation: 'armed' })
  208. expect(source.getSnapshot().activation).toBe('armed')
  209. dispose()
  210. expect(seen.length).toBeGreaterThan(0)
  211. })
  212. it('rejects every verb once the Remote namespace is gone', async () => {
  213. const b = await bench({ projection: makeProjection() })
  214. await b.fiber.await()
  215. const verbs = b.entry()!.inject!(sid('s1'))
  216. b.unmountGoals()
  217. // A missing namespace is an assembly fault, not a call outcome: this plugin
  218. // declares remote.goals in `inject`, so cordis disposes the dock entry along
  219. // with the namespace. Only a React closure that outlived that disposal can
  220. // reach these verbs, so no consumer-side guard renders it as an error.
  221. for (const verb of [() => verbs.onEdit('x'), () => verbs.onPause(), () => verbs.onResume(), () => verbs.onClear()]) {
  222. await expect(verb()).rejects.toThrow(TypeError)
  223. }
  224. expect(b.calls).toHaveLength(0)
  225. })
  226. it('a null or absent projection short-circuits every verb without touching the wire', async () => {
  227. for (const projection of [null, undefined]) {
  228. const b = await bench({ projection })
  229. await b.fiber.await()
  230. const verbs = b.entry()!.inject!(sid('s1'))
  231. for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
  232. expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } })
  233. }
  234. expect(b.calls).toHaveLength(0)
  235. }
  236. })
  237. it('forwards a Remote failure to the strip verbatim', async () => {
  238. const b = await bench({
  239. projection: makeProjection(),
  240. failWith: new RemoteError('gateway/internal', 'stale revision', {}),
  241. })
  242. await b.fiber.await()
  243. const verbs = b.entry()!.inject!(sid('s1'))
  244. expect(await verbs.onEdit('x')).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'stale revision' } })
  245. })
  246. it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
  247. const b = await bench()
  248. await b.fiber.await()
  249. expect(b.entry()).toBeDefined()
  250. expect(b.chatEntry()).toBeDefined()
  251. expect(b.definitions()).toHaveLength(1)
  252. await b.fiber.dispose()
  253. expect(b.entry()).toBeUndefined()
  254. expect(b.chatEntry()).toBeUndefined()
  255. expect(b.definitions()).toHaveLength(0)
  256. })
  257. })
  258. describe('GoalDock adapter', () => {
  259. it('renders the projected goal snapshot and nothing for absent/null', () => {
  260. const projection = makeProjection()
  261. const useProjection = vi.fn(() => projection)
  262. const useGoalActivation = (
  263. selector: (snapshot: GoalActivationSnapshot) => unknown,
  264. ) => selector({ id: GOAL_ID, revision: 3, activation: 'armed' })
  265. const actions: GoalBarActions = {
  266. onEdit: () => Promise.resolve({ ok: true, value: undefined }),
  267. onPause: () => Promise.resolve({ ok: true, value: undefined }),
  268. onResume: () => Promise.resolve({ ok: true, value: undefined }),
  269. onClear: () => Promise.resolve({ ok: true, value: undefined }),
  270. }
  271. const t = makeTranslate(zh, commonZh)
  272. const dockProps = (up: () => GoalProjection | null | undefined) =>
  273. ({ useProjection: up, useGoalActivation, ...actions, t }) as unknown as Parameters<typeof GoalDock>[0]
  274. const shown = render(<GoalDock {...dockProps(useProjection)} />)
  275. expect(shown.getByText('Ship it')).toBeTruthy()
  276. cleanup()
  277. const empty = render(<GoalDock {...dockProps(() => null)} />)
  278. expect(empty.container.firstChild).toBeNull()
  279. cleanup()
  280. const absent = render(<GoalDock {...dockProps(() => undefined)} />)
  281. expect(absent.container.firstChild).toBeNull()
  282. })
  283. it('matches activation by goal id and revision from the injected hook', () => {
  284. const projection = makeProjection()
  285. const useProjection = vi.fn(() => projection)
  286. const useGoalActivation = (
  287. selector: (snapshot: GoalActivationSnapshot) => unknown,
  288. ) => selector({ id: GOAL_ID, revision: 3, activation: 'disarmed' })
  289. const actions: GoalBarActions = {
  290. onEdit: () => Promise.resolve({ ok: true, value: undefined }),
  291. onPause: () => Promise.resolve({ ok: true, value: undefined }),
  292. onResume: () => Promise.resolve({ ok: true, value: undefined }),
  293. onClear: () => Promise.resolve({ ok: true, value: undefined }),
  294. }
  295. const t = makeTranslate(zh, commonZh)
  296. const props = { useProjection, useGoalActivation, ...actions, t } as unknown as Parameters<typeof GoalDock>[0]
  297. const rendered = render(<GoalDock {...props} />)
  298. expect(rendered.getByText('未运行的目标')).toBeTruthy()
  299. expect(screen.getByRole('button', { name: '恢复目标' })).toBeTruthy()
  300. expect(rendered.queryByRole('button', { name: '暂停目标' })).toBeNull()
  301. })
  302. })
  303. describe('ui-goal node half', () => {
  304. it('the node apply is an inert loader seat', () => {
  305. expect(() => { nodeApply() }).not.toThrow()
  306. })
  307. })