browser-plugin.client.spec.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. // @vitest-environment jsdom
  2. /**
  3. * ui-message-feedback browser half on a real cordis Context with fake slots/remote
  4. * faces: the plugin registers the feedback entry at
  5. * conversation.chat.assistant-actions and the dialog entry at
  6. * conversation.input.overlay, decorates the Host's /feedback command with an
  7. * action that opens the dialog, one surface per Session backs every entry in
  8. * that Session, a reconnect refreshes only Sessions that were already read,
  9. * and registration plus surface disposal ride the plugin fiber (HMR safety).
  10. * The node half stays inert.
  11. */
  12. import { Context, Service } from '@deepseek-ai/cordis'
  13. import { afterEach, describe, expect, it } from 'vitest'
  14. import { cleanup } from '@testing-library/react'
  15. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  16. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  17. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  18. import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
  19. import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
  20. import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
  21. import type { FeedbackDialogInjected, MessageFeedbackInjected } from '../src/client/slots.ts'
  22. import { apply, inject } from '../src/client/index.ts'
  23. import { apply as nodeApply } from '../src/index.ts'
  24. afterEach(cleanup)
  25. const sid = (k: string): SessionId => k as SessionId
  26. const MSG = 'm-1' as MessageId
  27. const seeded: MessageFeedbackItem = {
  28. messageId: MSG,
  29. rating: 'positive',
  30. version: 'v1' as MessageFeedbackVersion,
  31. createdAt: 1,
  32. updatedAt: 1,
  33. }
  34. /** Boot the plugin over fake faces; the Remote namespaces record every call. */
  35. async function bench(options: { recordResult?: unknown; recordCarrier?: unknown } = {}) {
  36. const ctx = new Context()
  37. const calls: { method: string; request: unknown }[] = []
  38. // The generated face wraps every business result in the carrier envelope.
  39. const carried = <T,>(value: T) => Promise.resolve({ ok: true as const, value })
  40. const messageFeedback = {
  41. list: (request: unknown) => {
  42. calls.push({ method: 'list', request })
  43. return carried({ ok: true as const, value: { items: [seeded] } })
  44. },
  45. put: (request: unknown) => {
  46. calls.push({ method: 'put', request })
  47. return carried({ ok: true as const, value: seeded })
  48. },
  49. delete: (request: unknown) => {
  50. calls.push({ method: 'delete', request })
  51. return carried({ ok: true as const, value: { absent: true as const } })
  52. },
  53. }
  54. class RemoteService extends Service {
  55. constructor(serviceCtx: Context) {
  56. super(serviceCtx, 'remote')
  57. }
  58. }
  59. const sessionFeedback = {
  60. record: (request: unknown) => {
  61. calls.push({ method: 'record', request })
  62. if (options.recordCarrier !== undefined) return Promise.resolve(options.recordCarrier)
  63. return carried(options.recordResult ?? { ok: true as const, value: { recorded: true as const } })
  64. },
  65. }
  66. new RemoteService(ctx)
  67. ctx.provide('remote.messageFeedback', messageFeedback)
  68. ctx.provide('remote.sessionFeedback', sessionFeedback)
  69. await ctx.plugin(SlotRegistry).await()
  70. ctx.slots.register({
  71. name: 'root',
  72. children: {
  73. 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
  74. 'conversation.input.overlay': { kind: 'list', scope: 'session' },
  75. },
  76. } as never, (() => null) as never)
  77. ctx.provide('locale', new LocaleRuntime(ctx))
  78. const decorations = new Map<string, CommandDecoration>()
  79. ctx.provide('commandUi', {
  80. decorate: (decoration: CommandDecoration) => {
  81. decorations.set(decoration.name, decoration)
  82. return () => { decorations.delete(decoration.name) }
  83. },
  84. })
  85. const fiber = ctx.plugin({ inject: [...inject], apply })
  86. return {
  87. ctx,
  88. fiber,
  89. calls,
  90. decorations,
  91. entry: () => {
  92. const entry = ctx.slots.entries('conversation.chat.assistant-actions')[0]
  93. if (entry === undefined) return undefined
  94. return {
  95. ...entry.options,
  96. locale: entry.locale,
  97. inject: entry.inject as unknown as ((sessionId: SessionId) => MessageFeedbackInjected) | undefined,
  98. }
  99. },
  100. dialogEntry: () => {
  101. const entry = ctx.slots.entries('conversation.input.overlay')[0]
  102. if (entry === undefined) return undefined
  103. return {
  104. ...entry.options,
  105. locale: entry.locale,
  106. inject: entry.inject as unknown as ((sessionId: SessionId) => FeedbackDialogInjected) | undefined,
  107. }
  108. },
  109. }
  110. }
  111. describe('ui-message-feedback browser plugin', () => {
  112. it('registers the feedback entry with the documented id, order, and locale', async () => {
  113. const b = await bench()
  114. await b.fiber.await()
  115. expect(b.entry()).toMatchObject({ id: 'feedback', order: 10, locale: 'feedback' })
  116. expect(b.entry()?.inject).toBeTypeOf('function')
  117. })
  118. it('exposes the feedback hook plus the ensure/toggle/openDialog/acknowledge verbs', async () => {
  119. const b = await bench()
  120. await b.fiber.await()
  121. const face = b.entry()!.inject!(sid('s1'))
  122. expect(face.hooks.feedback.getSnapshot()).toMatchObject({ status: 'cold' })
  123. expect(face.ensure).toBeTypeOf('function')
  124. expect(face.current(MSG)).toBeUndefined()
  125. await face.ensure()
  126. expect(face.current(MSG)).toMatchObject({ messageId: MSG, rating: 'positive' })
  127. expect(face.toggle).toBeTypeOf('function')
  128. expect(face.openDialog).toBeTypeOf('function')
  129. expect(face.acknowledge).toBeTypeOf('function')
  130. })
  131. it('registers the dialog entry with the documented id, order, and locale', async () => {
  132. const b = await bench()
  133. await b.fiber.await()
  134. expect(b.dialogEntry()).toMatchObject({ id: 'feedback-dialog', order: 2, locale: 'feedback' })
  135. const face = b.dialogEntry()!.inject!(sid('s1'))
  136. expect(face.hooks.dialog.getSnapshot()).toMatchObject({ target: null, toast: 0 })
  137. })
  138. it('opens one dialog per Session from the message entry, the decoration, and acknowledges from Like', async () => {
  139. const b = await bench()
  140. await b.fiber.await()
  141. const message = b.entry()!.inject!(sid('s1'))
  142. const dialog = b.dialogEntry()!.inject!(sid('s1'))
  143. message.openDialog(MSG)
  144. expect(dialog.hooks.dialog.getSnapshot().target).toEqual({ kind: 'message', messageId: MSG })
  145. const decoration = b.decorations.get('feedback')
  146. expect(decoration?.available({ sessionId: sid('s1') })).toBe(true)
  147. if (decoration?.ui.kind !== 'action') throw new Error('the /feedback decoration is not an action')
  148. decoration.ui.run({ sessionId: sid('s1') })
  149. expect(dialog.hooks.dialog.getSnapshot().target).toEqual({ kind: 'session' })
  150. message.acknowledge()
  151. expect(dialog.hooks.dialog.getSnapshot().toast).toBe(1)
  152. expect(b.dialogEntry()!.inject!(sid('s2')).hooks.dialog.getSnapshot()).toMatchObject({ target: null, toast: 0 })
  153. })
  154. it('records a Session remark through the sessionFeedback Remote and a message judgment through put', async () => {
  155. const b = await bench()
  156. await b.fiber.await()
  157. const message = b.entry()!.inject!(sid('s1'))
  158. const dialog = b.dialogEntry()!.inject!(sid('s1'))
  159. const decoration = b.decorations.get('feedback')
  160. if (decoration?.ui.kind !== 'action') throw new Error('the /feedback decoration is not an action')
  161. decoration.ui.run({ sessionId: sid('s1') })
  162. dialog.edit({ category: 'service-stability', text: 'timed out' })
  163. await dialog.submit()
  164. expect(b.calls.filter(call => call.method === 'record')[0]?.request)
  165. .toEqual({ sessionId: 's1', text: 'timed out', category: 'service-stability' })
  166. expect(dialog.hooks.dialog.getSnapshot()).toMatchObject({ target: null, toast: 1 })
  167. message.openDialog(MSG)
  168. dialog.edit({ category: 'task-result' })
  169. await dialog.submit()
  170. expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
  171. sessionId: 's1', messageId: MSG, rating: 'negative', category: 'task-result', ifVersion: 'v1',
  172. })
  173. expect(dialog.hooks.dialog.getSnapshot().toast).toBe(2)
  174. dialog.dismissToast(2)
  175. expect(dialog.hooks.dialog.getSnapshot().toast).toBe(0)
  176. })
  177. it('keeps the dialog open with the carrier code when the record call itself fails', async () => {
  178. const b = await bench({ recordCarrier: { ok: false as const, error: { code: 'gateway/internal', message: 'socket closed', details: {} } } })
  179. await b.fiber.await()
  180. const dialog = b.dialogEntry()!.inject!(sid('s1'))
  181. const decoration = b.decorations.get('feedback')
  182. if (decoration?.ui.kind !== 'action') throw new Error('the /feedback decoration is not an action')
  183. decoration.ui.run({ sessionId: sid('s1') })
  184. await dialog.submit()
  185. expect(dialog.hooks.dialog.getSnapshot()).toMatchObject({ target: { kind: 'session' }, failure: 'gateway/internal', toast: 0 })
  186. })
  187. it('keeps the dialog open with the failure code when the Host rejects the remark', async () => {
  188. const b = await bench({
  189. recordResult: { ok: false as const, error: { code: 'session-not-found', sessionId: 'gone' } },
  190. })
  191. await b.fiber.await()
  192. const dialog = b.dialogEntry()!.inject!(sid('gone'))
  193. const decoration = b.decorations.get('feedback')
  194. if (decoration?.ui.kind !== 'action') throw new Error('the /feedback decoration is not an action')
  195. decoration.ui.run({ sessionId: sid('gone') })
  196. await dialog.submit()
  197. expect(dialog.hooks.dialog.getSnapshot()).toMatchObject({ target: { kind: 'session' }, failure: 'session-not-found' })
  198. dialog.dismiss()
  199. expect(dialog.hooks.dialog.getSnapshot().target).toBeNull()
  200. })
  201. it('shares one controller across every message in the same Session', async () => {
  202. const b = await bench()
  203. await b.fiber.await()
  204. const first = b.entry()!.inject!(sid('s1'))
  205. const second = b.entry()!.inject!(sid('s1'))
  206. expect(first.hooks.feedback).toBe(second.hooks.feedback)
  207. await first.ensure()
  208. await second.ensure()
  209. expect(b.calls.filter(call => call.method === 'list')).toHaveLength(1)
  210. })
  211. it('keeps separate Sessions on separate controllers', async () => {
  212. const b = await bench()
  213. await b.fiber.await()
  214. const one = b.entry()!.inject!(sid('s1'))
  215. const two = b.entry()!.inject!(sid('s2'))
  216. expect(one.hooks.feedback).not.toBe(two.hooks.feedback)
  217. await one.ensure()
  218. await two.ensure()
  219. expect(b.calls.filter(call => call.method === 'list').map(call => call.request)).toEqual([
  220. { sessionId: 's1' },
  221. { sessionId: 's2' },
  222. ])
  223. })
  224. it('routes toggle to the Remote with the addressed message', async () => {
  225. const b = await bench()
  226. await b.fiber.await()
  227. const face = b.entry()!.inject!(sid('s1'))
  228. // The seeded item is positive, so a negative toggle replaces it through
  229. // put and a positive one retracts it through delete.
  230. expect(await face.toggle(MSG, 'negative')).toEqual({ ok: true, rating: 'negative' })
  231. expect(await face.toggle(MSG, 'positive')).toEqual({ ok: true, rating: null })
  232. expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
  233. sessionId: 's1', messageId: MSG, rating: 'negative',
  234. })
  235. expect(b.calls.filter(call => call.method === 'delete')[0]?.request).toMatchObject({
  236. sessionId: 's1', messageId: MSG,
  237. })
  238. })
  239. it('refreshes only Sessions already read when the connection resets', async () => {
  240. const b = await bench()
  241. await b.fiber.await()
  242. const warm = b.entry()!.inject!(sid('warm'))
  243. await warm.ensure()
  244. b.entry()!.inject!(sid('cold'))
  245. const before = b.calls.filter(call => call.method === 'list').length
  246. b.ctx.emit('connection/reset')
  247. await Promise.resolve()
  248. const reads = b.calls.filter(call => call.method === 'list')
  249. expect(reads).toHaveLength(before + 1)
  250. expect(reads.at(-1)?.request).toEqual({ sessionId: 'warm' })
  251. })
  252. it('withdraws the registrations and disposes surfaces with the plugin fiber', async () => {
  253. const b = await bench()
  254. await b.fiber.await()
  255. const face = b.entry()!.inject!(sid('s1'))
  256. const dialog = b.dialogEntry()!.inject!(sid('s1'))
  257. await face.ensure()
  258. face.openDialog(MSG)
  259. await b.fiber.dispose()
  260. expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(0)
  261. expect(b.ctx.slots.entries('conversation.input.overlay')).toHaveLength(0)
  262. expect(b.decorations.size).toBe(0)
  263. expect(dialog.hooks.dialog.getSnapshot().target).toBeNull()
  264. // A disposed controller refuses further mutations, so no request outlives the fiber.
  265. const before = b.calls.length
  266. expect(await face.toggle(MSG, 'negative')).toMatchObject({ ok: false, error: { code: 'disposed' } })
  267. expect(b.calls).toHaveLength(before)
  268. })
  269. it('re-registers cleanly when the plugin is reloaded', async () => {
  270. const b = await bench()
  271. await b.fiber.await()
  272. await b.fiber.dispose()
  273. const reloaded = b.ctx.plugin({ inject: [...inject], apply })
  274. await reloaded.await()
  275. expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(1)
  276. expect(b.entry()).toMatchObject({ id: 'feedback' })
  277. })
  278. it('the node half applies without host-side behavior', () => {
  279. expect(() => { nodeApply() }).not.toThrow()
  280. })
  281. })