browser-plugin.client.spec.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  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, one controller per Session backs every
  6. * message in that Session, a reconnect refreshes only Sessions that were
  7. * already read, and registration plus controller disposal ride the plugin
  8. * fiber (HMR safety). The node half and the invariant companion are exercised
  9. * over the same Context.
  10. */
  11. import { Context, Service } from '@deepseek-ai/cordis'
  12. import { afterEach, describe, expect, it } from 'vitest'
  13. import { cleanup } from '@testing-library/react'
  14. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  15. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  16. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  17. import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
  18. import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
  19. import type { MessageFeedbackInjected } from '../src/client/slots.ts'
  20. import { apply, inject } from '../src/client/index.ts'
  21. import { apply as nodeApply } from '../src/index.ts'
  22. afterEach(cleanup)
  23. const sid = (k: string): SessionId => k as SessionId
  24. const MSG = 'm-1' as MessageId
  25. const seeded: MessageFeedbackItem = {
  26. messageId: MSG,
  27. rating: 'positive',
  28. version: 'v1' as MessageFeedbackVersion,
  29. createdAt: 1,
  30. updatedAt: 1,
  31. }
  32. /** Boot the plugin over fake faces; the Remote namespace records every call. */
  33. async function bench() {
  34. const ctx = new Context()
  35. const calls: { method: string; request: unknown }[] = []
  36. // The generated face wraps every business result in the carrier envelope.
  37. const carried = <T,>(value: T) => Promise.resolve({ ok: true as const, value })
  38. const messageFeedback = {
  39. list: (request: unknown) => {
  40. calls.push({ method: 'list', request })
  41. return carried({ ok: true as const, value: { items: [seeded] } })
  42. },
  43. put: (request: unknown) => {
  44. calls.push({ method: 'put', request })
  45. return carried({ ok: true as const, value: seeded })
  46. },
  47. delete: (request: unknown) => {
  48. calls.push({ method: 'delete', request })
  49. return carried({ ok: true as const, value: { absent: true as const } })
  50. },
  51. }
  52. class RemoteService extends Service {
  53. constructor(serviceCtx: Context) {
  54. super(serviceCtx, 'remote')
  55. }
  56. }
  57. new RemoteService(ctx)
  58. ctx.provide('remote.messageFeedback', messageFeedback)
  59. await ctx.plugin(SlotRegistry).await()
  60. ctx.slots.register({
  61. name: 'root',
  62. children: { 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' } },
  63. } as never, (() => null) as never)
  64. ctx.provide('locale', new LocaleRuntime(ctx))
  65. const fiber = ctx.plugin({ inject: [...inject], apply })
  66. return {
  67. ctx,
  68. fiber,
  69. calls,
  70. entry: () => {
  71. const entry = ctx.slots.entries('conversation.chat.assistant-actions')[0]
  72. if (entry === undefined) return undefined
  73. return {
  74. ...entry.options,
  75. locale: entry.locale,
  76. inject: entry.inject as unknown as ((sessionId: SessionId) => MessageFeedbackInjected) | undefined,
  77. }
  78. },
  79. }
  80. }
  81. describe('ui-message-feedback browser plugin', () => {
  82. it('registers the feedback entry with the documented id, order, and locale', async () => {
  83. const b = await bench()
  84. await b.fiber.await()
  85. expect(b.entry()).toMatchObject({ id: 'feedback', order: 10, locale: 'feedback' })
  86. expect(b.entry()?.inject).toBeTypeOf('function')
  87. })
  88. it('exposes the feedback hook plus the ensure/rate/clear verbs', async () => {
  89. const b = await bench()
  90. await b.fiber.await()
  91. const face = b.entry()!.inject!(sid('s1'))
  92. expect(face.hooks.feedback.getSnapshot()).toMatchObject({ status: 'cold' })
  93. expect(face.ensure).toBeTypeOf('function')
  94. expect(face.rate).toBeTypeOf('function')
  95. expect(face.clear).toBeTypeOf('function')
  96. })
  97. it('shares one controller across every message in the same Session', async () => {
  98. const b = await bench()
  99. await b.fiber.await()
  100. const first = b.entry()!.inject!(sid('s1'))
  101. const second = b.entry()!.inject!(sid('s1'))
  102. expect(first.hooks.feedback).toBe(second.hooks.feedback)
  103. await first.ensure()
  104. await second.ensure()
  105. expect(b.calls.filter(call => call.method === 'list')).toHaveLength(1)
  106. })
  107. it('keeps separate Sessions on separate controllers', async () => {
  108. const b = await bench()
  109. await b.fiber.await()
  110. const one = b.entry()!.inject!(sid('s1'))
  111. const two = b.entry()!.inject!(sid('s2'))
  112. expect(one.hooks.feedback).not.toBe(two.hooks.feedback)
  113. await one.ensure()
  114. await two.ensure()
  115. expect(b.calls.filter(call => call.method === 'list').map(call => call.request)).toEqual([
  116. { sessionId: 's1' },
  117. { sessionId: 's2' },
  118. ])
  119. })
  120. it('routes rate and clear to the Remote with the addressed message', async () => {
  121. const b = await bench()
  122. await b.fiber.await()
  123. const face = b.entry()!.inject!(sid('s1'))
  124. expect(await face.rate(MSG, 'negative', 'wrong answer')).toEqual({ ok: true })
  125. expect(await face.clear(MSG)).toEqual({ ok: true })
  126. expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
  127. sessionId: 's1', messageId: MSG, rating: 'negative', note: 'wrong answer',
  128. })
  129. expect(b.calls.filter(call => call.method === 'delete')[0]?.request).toMatchObject({
  130. sessionId: 's1', messageId: MSG,
  131. })
  132. })
  133. it('routes toggle and clearNote to the controller', async () => {
  134. const b = await bench()
  135. await b.fiber.await()
  136. const face = b.entry()!.inject!(sid('s1'))
  137. expect(await face.toggle(MSG, 'negative')).toEqual({ ok: true })
  138. expect(await face.clearNote(MSG)).toEqual({ ok: true })
  139. // The seeded item is positive with no note, so a negative toggle replaces it
  140. // through put, and clearNote has nothing to drop and touches no wire.
  141. const puts = b.calls.filter(call => call.method === 'put').map(call => call.request)
  142. expect(puts).toHaveLength(1)
  143. expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'negative' })
  144. })
  145. it('refreshes only Sessions already read when the connection resets', async () => {
  146. const b = await bench()
  147. await b.fiber.await()
  148. const warm = b.entry()!.inject!(sid('warm'))
  149. await warm.ensure()
  150. b.entry()!.inject!(sid('cold'))
  151. const before = b.calls.filter(call => call.method === 'list').length
  152. b.ctx.emit('connection/reset')
  153. await Promise.resolve()
  154. const reads = b.calls.filter(call => call.method === 'list')
  155. expect(reads).toHaveLength(before + 1)
  156. expect(reads.at(-1)?.request).toEqual({ sessionId: 'warm' })
  157. })
  158. it('withdraws the registration and disposes controllers with the plugin fiber', async () => {
  159. const b = await bench()
  160. await b.fiber.await()
  161. const face = b.entry()!.inject!(sid('s1'))
  162. await face.ensure()
  163. await b.fiber.dispose()
  164. expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(0)
  165. // A disposed controller refuses further mutations, so no request outlives the fiber.
  166. const before = b.calls.length
  167. expect(await face.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
  168. expect(b.calls).toHaveLength(before)
  169. })
  170. it('re-registers cleanly when the plugin is reloaded', async () => {
  171. const b = await bench()
  172. await b.fiber.await()
  173. await b.fiber.dispose()
  174. const reloaded = b.ctx.plugin({ inject: [...inject], apply })
  175. await reloaded.await()
  176. expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(1)
  177. expect(b.entry()).toMatchObject({ id: 'feedback' })
  178. })
  179. it('the node half applies without host-side behavior', () => {
  180. // The invariant companion is mounted by the vitest-wide invariant host on
  181. // every Context this suite creates; its registration is covered there.
  182. expect(() => { nodeApply() }).not.toThrow()
  183. })
  184. })