remote-events.host.spec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { Context } from '@deepseek-ai/cordis'
  2. import type { Fiber } from '@deepseek-ai/cordis'
  3. import type {
  4. RemoteEventHostInfo,
  5. TypertRemoteEventInvocation,
  6. TypertRemoteEventSource,
  7. } from '@deepseek-ai/dsh-api-gateway'
  8. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  9. import { describe, expect, it } from 'vitest'
  10. import { apply, inject } from '../src/index.ts'
  11. interface GatewayProbe {
  12. source: TypertRemoteEventSource | undefined
  13. host: RemoteEventHostInfo | undefined
  14. removals: number
  15. registerRemoteEvents(
  16. source: TypertRemoteEventSource,
  17. host: RemoteEventHostInfo,
  18. ): () => Promise<void>
  19. }
  20. async function setup(): Promise<{
  21. readonly ctx: Context
  22. readonly gateway: GatewayProbe
  23. readonly fiber: Fiber
  24. }> {
  25. const ctx = new Context()
  26. const gateway: GatewayProbe = {
  27. source: undefined,
  28. host: undefined,
  29. removals: 0,
  30. registerRemoteEvents(source, host) {
  31. gateway.source = source
  32. gateway.host = host
  33. return async () => {
  34. if (gateway.source !== source) return
  35. gateway.source = undefined
  36. gateway.host = undefined
  37. gateway.removals += 1
  38. }
  39. },
  40. }
  41. ctx.reflect.provide('typertGateway', gateway)
  42. const fiber = ctx.plugin({ inject: [...inject], apply })
  43. await fiber
  44. return { ctx, gateway, fiber }
  45. }
  46. function sourceOf(gateway: GatewayProbe): TypertRemoteEventSource {
  47. if (gateway.source === undefined) throw new Error('fixture Gateway has no Remote event source')
  48. return gateway.source
  49. }
  50. function emitRaw(ctx: Context, event: string, args: readonly unknown[]): void {
  51. const emit = ctx.emit.bind(ctx) as unknown as (name: string, ...values: readonly unknown[]) => void
  52. emit(event, ...args)
  53. }
  54. function waterfallRaw(
  55. ctx: Context,
  56. target: object,
  57. event: string,
  58. args: readonly unknown[],
  59. next: () => Promise<unknown>,
  60. ): Promise<unknown> {
  61. const waterfall = ctx.waterfall.bind(ctx) as unknown as (
  62. receiver: object,
  63. name: string,
  64. ...values: readonly unknown[]
  65. ) => Promise<unknown>
  66. return waterfall(target, event, ...args, next)
  67. }
  68. function invocationOf(value: unknown): TypertRemoteEventInvocation {
  69. if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'context')) {
  70. throw new Error('fixture did not receive a scoped Remote Event invocation')
  71. }
  72. return value as TypertRemoteEventInvocation
  73. }
  74. describe('Remote event Host source', () => {
  75. it('registers the Host home used by Client connection generations', async () => {
  76. const { gateway, fiber } = await setup()
  77. expect(gateway.host?.home).toBeTypeOf('string')
  78. expect(gateway.host?.home.length).toBeGreaterThan(0)
  79. await fiber.dispose()
  80. expect(gateway.host).toBeUndefined()
  81. })
  82. it('gives each Client stream an independent allowlisted event queue', async () => {
  83. const { ctx, gateway, fiber } = await setup()
  84. const firstAbort = new AbortController()
  85. const secondAbort = new AbortController()
  86. const first = sourceOf(gateway)(firstAbort.signal)[Symbol.asyncIterator]()
  87. const second = sourceOf(gateway)(secondAbort.signal)[Symbol.asyncIterator]()
  88. emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1])
  89. await expect(first.next()).resolves.toEqual({
  90. done: false,
  91. value: { event: 'settings/document-updated', args: ['ui-theme', 1] },
  92. })
  93. await expect(second.next()).resolves.toEqual({
  94. done: false,
  95. value: { event: 'settings/document-updated', args: ['ui-theme', 1] },
  96. })
  97. emitRaw(ctx, 'goal/activation-changed', [{
  98. sessionId: 'session-1',
  99. goal: { id: 'goal-1', revision: 1, activation: 'disarmed' },
  100. }])
  101. await expect(first.next()).resolves.toEqual({
  102. done: false,
  103. value: {
  104. event: 'goal/activation-changed',
  105. args: [{ sessionId: 'session-1', goal: { id: 'goal-1', revision: 1, activation: 'disarmed' } }],
  106. },
  107. })
  108. await expect(second.next()).resolves.toEqual({
  109. done: false,
  110. value: {
  111. event: 'goal/activation-changed',
  112. args: [{ sessionId: 'session-1', goal: { id: 'goal-1', revision: 1, activation: 'disarmed' } }],
  113. },
  114. })
  115. const firstDone = first.next()
  116. firstAbort.abort(new Error('first Client disconnected'))
  117. emitRaw(ctx, 'commands/change', [])
  118. await expect(firstDone).resolves.toEqual({ done: true, value: undefined })
  119. await expect(second.next()).resolves.toEqual({
  120. done: false,
  121. value: { event: 'commands/change', args: [] },
  122. })
  123. const secondDone = second.next()
  124. secondAbort.abort(new Error('second Client disconnected'))
  125. await expect(secondDone).resolves.toEqual({ done: true, value: undefined })
  126. await fiber.dispose()
  127. expect(gateway.source).toBeUndefined()
  128. expect(gateway.removals).toBe(1)
  129. await ctx.fiber.dispose()
  130. })
  131. it('rejects a non-JSON argument without poisoning the stream', async () => {
  132. const { ctx, gateway } = await setup()
  133. const abort = new AbortController()
  134. const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
  135. const pending = iterator.next()
  136. expect(() => {
  137. emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1n])
  138. }).toThrow('argument 1 is not lossless JSON data')
  139. emitRaw(ctx, 'settings/document-updated', ['ui-theme', 2])
  140. await expect(pending).resolves.toEqual({
  141. done: false,
  142. value: { event: 'settings/document-updated', args: ['ui-theme', 2] },
  143. })
  144. const done = iterator.next()
  145. abort.abort()
  146. await expect(done).resolves.toEqual({ done: true, value: undefined })
  147. const alreadyAborted = new AbortController()
  148. alreadyAborted.abort()
  149. await expect(sourceOf(gateway)(alreadyAborted.signal)[Symbol.asyncIterator]().next())
  150. .resolves.toEqual({ done: true, value: undefined })
  151. await ctx.fiber.dispose()
  152. })
  153. it('bridges scoped waterfall result, next delegation, and rejection', async () => {
  154. const { ctx, gateway } = await setup()
  155. const abort = new AbortController()
  156. const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
  157. const agentCtx = ctx.extend()
  158. const agent = { id: 'agent-1', ctx: agentCtx }
  159. const target = scopeTarget(ctx, agent)
  160. const request = { questions: [], agent }
  161. await expect(async () => waterfallRaw(
  162. ctx,
  163. target,
  164. 'user-questions/request',
  165. [{ questions: [], agent: { id: 'agent-2', ctx: ctx.extend() } }],
  166. () => Promise.resolve('host fallback'),
  167. )).rejects.toThrow('must carry its Agent directly')
  168. const claimed = waterfallRaw(
  169. ctx,
  170. target,
  171. 'user-questions/request',
  172. [request],
  173. () => Promise.resolve('host fallback'),
  174. )
  175. const claimedDispatch = invocationOf((await iterator.next()).value)
  176. expect(claimedDispatch).toMatchObject({
  177. event: 'user-questions/request',
  178. request,
  179. context: { value: agentCtx, subject: agent, agentId: 'agent-1' },
  180. })
  181. claimedDispatch.resolve({ kind: 'result', value: 'client answer' })
  182. await expect(claimed).resolves.toBe('client answer')
  183. const delegated = waterfallRaw(
  184. ctx,
  185. target,
  186. 'user-questions/request',
  187. [request],
  188. () => Promise.resolve('host fallback'),
  189. )
  190. const delegatedDispatch = invocationOf((await iterator.next()).value)
  191. delegatedDispatch.resolve({ kind: 'next' })
  192. await expect(delegated).resolves.toBe('host fallback')
  193. const rejection = Object.assign(new Error('the user cancelled ask_user_question'), {
  194. code: 'ASK_CANCELLED',
  195. })
  196. const rejected = waterfallRaw(
  197. ctx,
  198. target,
  199. 'user-questions/request',
  200. [request],
  201. () => Promise.resolve('host fallback'),
  202. )
  203. const rejectedAssertion = expect(rejected).rejects.toBe(rejection)
  204. const rejectedDispatch = invocationOf((await iterator.next()).value)
  205. rejectedDispatch.reject(rejection)
  206. await rejectedAssertion
  207. const done = iterator.next()
  208. abort.abort()
  209. await expect(done).resolves.toEqual({ done: true, value: undefined })
  210. await ctx.fiber.dispose()
  211. })
  212. it('rejects a queued scoped waterfall when its source is withdrawn', async () => {
  213. const { ctx, gateway, fiber } = await setup()
  214. const abort = new AbortController()
  215. const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
  216. const delivery = iterator.next()
  217. const agent = { id: 'agent-1', ctx: ctx.extend() }
  218. const reason = new Error('forwarded event source removed')
  219. const pending = waterfallRaw(
  220. ctx,
  221. scopeTarget(ctx, agent),
  222. 'user-questions/request',
  223. [{ questions: [], agent }],
  224. () => Promise.resolve('host fallback'),
  225. )
  226. const rejected = expect(pending).rejects.toBe(reason)
  227. abort.abort(reason)
  228. await rejected
  229. await expect(delivery).resolves.toEqual({ done: true, value: undefined })
  230. await fiber.dispose()
  231. await ctx.fiber.dispose()
  232. })
  233. })