proxy.client.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. /** Native namespace mocks shared by local callers and the Connection carrier. */
  2. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  3. import { RemoteMock, frames, ok, openStream, type StreamHandle } from '../src/index.ts'
  4. const describeValue = (hasDocument: boolean) => ok({ writable: true, hasDocument, namespaces: [] })
  5. const baseline = { type: 'baseline' as const, value: { queues: {}, jobs: {}, projections: {} } }
  6. async function drain(source: AsyncIterable<unknown>): Promise<unknown[]> {
  7. const values: unknown[] = []
  8. for await (const value of source) values.push(value)
  9. return values
  10. }
  11. describe('RemoteMock.remote unary proxies', () => {
  12. it('caches native namespace/method identities and leaves thenable/symbol probes inert', async () => {
  13. const mock = RemoteMock.create()
  14. const settings = mock.remote.settings
  15. const describe = settings.describe
  16. expect(mock.remote.settings).toBe(settings)
  17. expect(settings.describe).toBe(describe)
  18. expect(vi.isMockFunction(describe)).toBe(true)
  19. expect(RemoteMock.create().remote.settings.describe).not.toBe(describe)
  20. for (const value of [mock.remote, settings]) {
  21. expect(Reflect.get(value, 'then')).toBeUndefined()
  22. expect(Reflect.get(value, Symbol.iterator)).toBeUndefined()
  23. expect(Reflect.get(value, Symbol.toStringTag)).toBeUndefined()
  24. expect(await Promise.resolve(value)).toBe(value)
  25. }
  26. expect(describe).not.toHaveBeenCalled()
  27. expect(mock.endpoints()).toEqual(['$events', 'settings/describe'])
  28. expect(mock.log.calls()).toEqual([])
  29. expect(mock.log.streams()).toEqual([])
  30. expect(mock.log.unmatched()).toEqual([])
  31. })
  32. it('consumes native one-shot answers in FIFO order across direct and carrier calls, then uses the default handler', async () => {
  33. const fallback = describeValue(false)
  34. const local = describeValue(true)
  35. const dispatched = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } }
  36. const carrier = describeValue(true)
  37. const handler = vi.fn(() => fallback)
  38. const mock = RemoteMock.create().unary('settings/describe', handler)
  39. const describe = mock.remote.settings.describe
  40. describe.mockReturnValueOnce(Promise.resolve(local)).mockResolvedValueOnce(dispatched).mockResolvedValueOnce(carrier)
  41. await expect(describe()).resolves.toBe(local)
  42. await expect(mock.dispatch('settings/describe', [])).resolves.toBe(dispatched)
  43. await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(carrier)
  44. await expect(describe()).resolves.toBe(fallback)
  45. expect(handler).toHaveBeenCalledOnce()
  46. expect(describe.mock.calls).toEqual([[], [], [], []])
  47. expect(mock.log.calls('settings/describe').map(call => [call.args, call.state, call.result]))
  48. .toEqual([[[], 'answered', dispatched], [[], 'answered', carrier]])
  49. mock.assertNoUnmatched()
  50. })
  51. it('records every business argument while the carrier keeps its signal out of positional args', async () => {
  52. const mock = RemoteMock.create()
  53. const mutate = mock.remote.settings.mutate
  54. const result = ok({ ns: 'locale', schema: {}, value: { preference: 'en' }, applies: 'live' as const, secrets: [], revision: 8 })
  55. const ops = [{ op: 'set' as const, path: ['preference'], value: 'en' }]
  56. mutate.mockResolvedValue(result)
  57. await expect(mutate('locale', ops, 7)).resolves.toBe(result)
  58. await expect(mock.rpc.call('/api', 'settings/mutate', { args: ['locale', ops, 7] }, new AbortController().signal))
  59. .resolves.toBe(result)
  60. expect(mutate.mock.calls).toEqual([['locale', ops, 7], ['locale', ops, 7]])
  61. expect(mutate.mock.calls[1]?.[1]).toBe(ops)
  62. expect(mock.log.calls('settings/mutate').map(call => call.args)).toEqual([['locale', ops, 7]])
  63. })
  64. it('keeps native one-shot answers local to each mock when they load the same default table', async () => {
  65. const fallback = describeValue(false)
  66. const table = { unary: { 'settings/describe': fallback } }
  67. const a = RemoteMock.create().load(table)
  68. const b = RemoteMock.create().load(table)
  69. const first = describeValue(true)
  70. const second = { ...describeValue(true), value: { writable: false, hasDocument: true, namespaces: [] } }
  71. a.remote.settings.describe.mockResolvedValueOnce(first)
  72. b.remote.settings.describe.mockResolvedValueOnce(second)
  73. await expect(a.dispatch('settings/describe', [])).resolves.toBe(first)
  74. await expect(a.dispatch('settings/describe', [])).resolves.toBe(fallback)
  75. expect(b.remote.settings.describe).not.toHaveBeenCalled()
  76. await expect(b.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(second)
  77. await expect(b.remote.settings.describe()).resolves.toBe(fallback)
  78. })
  79. it('keeps queued overrides on mockClear and restores the live default rule on mockReset', async () => {
  80. const original = describeValue(false)
  81. const updated = describeValue(true)
  82. const override = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } }
  83. const mock = RemoteMock.create().unary('settings/describe', original)
  84. const describe = mock.remote.settings.describe
  85. await describe()
  86. describe.mockResolvedValueOnce(override)
  87. describe.mockClear()
  88. await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(override)
  89. expect(describe.mock.calls).toEqual([[]])
  90. describe.mockResolvedValue(override)
  91. mock.load({ unary: { 'settings/describe': updated } })
  92. await expect(mock.dispatch('settings/describe', [])).resolves.toBe(override)
  93. describe.mockReset()
  94. await expect(mock.dispatch('settings/describe', [])).resolves.toBe(updated)
  95. expect(mock.remote.settings.describe).toBe(describe)
  96. expect(describe.mock.calls).toEqual([[]])
  97. expect(mock.log.calls('settings/describe').map(call => call.result)).toEqual([override, override, updated])
  98. })
  99. it('reports missing defaults after native one-shot answers are exhausted', async () => {
  100. const mock = RemoteMock.create()
  101. const describe = mock.remote.settings.describe
  102. expect(mock.modeOf('settings/describe')).toBeUndefined()
  103. const answer = describeValue(false)
  104. describe.mockResolvedValueOnce(answer)
  105. await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(answer)
  106. expect(() => describe()).toThrow('no rule for settings/describe')
  107. await expect(mock.dispatch('settings/describe', [])).rejects.toThrow('no rule for settings/describe')
  108. await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).rejects.toThrow('no rule for settings/describe')
  109. expect(describe).toHaveBeenCalledTimes(4)
  110. expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['answered', answer]])
  111. expect(mock.log.unmatched()).toEqual(Array.from({ length: 3 }, () => ({ endpoint: 'settings/describe', mode: 'unary' })))
  112. expect(() => { mock.assertNoUnmatched() }).toThrow('3 unmatched request(s)')
  113. })
  114. it('records native rejected answers as failed carrier calls without unmatched entries', async () => {
  115. const mock = RemoteMock.create()
  116. const failure = new Error('mock service unavailable')
  117. const describe = mock.remote.settings.describe.mockRejectedValueOnce(failure)
  118. await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).rejects.toBe(failure)
  119. expect(describe.mock.calls).toEqual([[]])
  120. expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['failed', failure]])
  121. mock.assertNoUnmatched()
  122. })
  123. it('preserves synchronous native override failures in the async carrier log', async () => {
  124. const mock = RemoteMock.create()
  125. const failure = new Error('handler threw')
  126. const describe = mock.remote.settings.describe.mockImplementation(() => { throw failure })
  127. await expect(mock.dispatch('settings/describe', [])).rejects.toBe(failure)
  128. expect(describe).toHaveBeenCalledOnce()
  129. expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['failed', failure]])
  130. mock.assertNoUnmatched()
  131. })
  132. })
  133. describe('RemoteMock.remote stream proxies', () => {
  134. it('selects the cached spy for the current mode and preserves its identity across script replacement', async () => {
  135. const mock = RemoteMock.create()
  136. const session = mock.remote.session
  137. const unary = session.control
  138. expect(() => unary()).toThrow('no rule for session/control')
  139. expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/control', mode: 'unary' }])
  140. mock.stream('session/control', frames([baseline])).stream('session/control')
  141. const stream = session.control
  142. expect(mock.remote.session).toBe(session)
  143. expect(stream).not.toBe(unary)
  144. expect(vi.isMockFunction(stream)).toBe(true)
  145. expect(session.control).toBe(stream)
  146. const opened = stream()
  147. mock.stream('session/control', frames([]))
  148. expect(session.control).toBe(stream)
  149. await expect(drain(opened)).resolves.toEqual([baseline])
  150. await expect(drain(stream())).resolves.toEqual([])
  151. expect(unary).toHaveBeenCalledOnce()
  152. expect(stream).toHaveBeenCalledTimes(2)
  153. expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['ended', 'ended'])
  154. })
  155. it('shares stream spies with the carrier, preserves its args, and reflects each caller cancellation', async () => {
  156. const signals: AbortSignal[] = []
  157. const script = vi.fn((_args: readonly unknown[], stream: StreamHandle) => {
  158. signals.push(stream.signal)
  159. stream.push(baseline)
  160. })
  161. const mock = RemoteMock.create().stream('session/control', script)
  162. const localController = new AbortController()
  163. const wireController = new AbortController()
  164. onTestFinished(async () => {
  165. localController.abort()
  166. wireController.abort()
  167. await mock.streams.drained('session/control')
  168. })
  169. const control = mock.remote.session.control
  170. const request = { after: 7 }
  171. const local = control(localController.signal)[Symbol.asyncIterator]()
  172. const wire = mock.rpc.open!('/api', 'session/control', { args: [request] }, wireController.signal)[Symbol.asyncIterator]()
  173. await expect(local.next()).resolves.toEqual({ value: baseline, done: false })
  174. await expect(wire.next()).resolves.toEqual({ value: baseline, done: false })
  175. expect(control.mock.calls).toEqual([[localController.signal], [request, wireController.signal]])
  176. expect(script.mock.calls.map(([args]) => args)).toEqual([[], [request]])
  177. expect(signals).toHaveLength(2)
  178. expect(signals.map(signal => signal.aborted)).toEqual([false, false])
  179. expect(mock.log.streams('session/control').map(entry => entry.args)).toEqual([[], [request]])
  180. const waiting = local.next()
  181. localController.abort()
  182. await expect(waiting).resolves.toEqual({ value: undefined, done: true })
  183. await wire.return!()
  184. await mock.streams.drained('session/control')
  185. expect(signals.map(signal => signal.aborted)).toEqual([true, true])
  186. expect(mock.streams.push('session/control', baseline)).toBe(0)
  187. expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['cancelled', 'cancelled'])
  188. })
  189. it('provides a cancellable default signal when a local stream call omits one', async () => {
  190. let openedSignal: AbortSignal | undefined
  191. const mock = RemoteMock.create().stream('session/control', (_args, stream) => { openedSignal = stream.signal })
  192. const control = mock.remote.session.control
  193. const reader = control()[Symbol.asyncIterator]()
  194. onTestFinished(async () => { await reader.return!() })
  195. expect(openedSignal).toBeInstanceOf(AbortSignal)
  196. expect(openedSignal?.aborted).toBe(false)
  197. const waiting = reader.next()
  198. await reader.return!()
  199. await expect(waiting).resolves.toEqual({ value: undefined, done: true })
  200. expect(control.mock.calls).toEqual([[]])
  201. expect(mock.log.streams('session/control')[0]?.state).toBe('cancelled')
  202. })
  203. it('excludes unread native stream overrides from controlled streams and restores the script on reset', async () => {
  204. const mock = RemoteMock.create().stream('session/control', frames([baseline]))
  205. const control = mock.remote.session.control
  206. const signal = new AbortController().signal
  207. let consumed = false
  208. async function* response() {
  209. consumed = true
  210. yield baseline
  211. }
  212. const native = response()
  213. onTestFinished(async () => { await native.return(undefined) })
  214. control.mockReturnValueOnce(native)
  215. const overridden = mock.open('session/control', [], signal)
  216. expect(overridden).toBe(native)
  217. await expect(mock.streams.drained('session/control')).resolves.toBeUndefined()
  218. expect(consumed).toBe(false)
  219. expect(control.mock.calls).toEqual([[signal]])
  220. expect(mock.log.streams('session/control')).toHaveLength(0)
  221. await expect(drain(overridden)).resolves.toEqual([baseline])
  222. expect(consumed).toBe(true)
  223. control.mockReset()
  224. await expect(drain(mock.rpc.open!('/api', 'session/control', { args: [] }, signal))).resolves.toEqual([baseline])
  225. await expect(mock.streams.opened('session/control', 1)).resolves.toBeUndefined()
  226. await expect(mock.streams.drained('session/control')).resolves.toBeUndefined()
  227. expect(mock.remote.session.control).toBe(control)
  228. expect(control.mock.calls).toEqual([[signal]])
  229. expect(mock.log.streams('session/control').map(entry => [entry.state, entry.pushed])).toEqual([['ended', 1]])
  230. mock.assertNoUnmatched()
  231. })
  232. it('requires an explicit stream script and keeps live failures observable by the consumer', async () => {
  233. const mock = RemoteMock.create().stream('session/control')
  234. expect(() => mock.remote.session.control()).toThrow('no rule for session/control')
  235. expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/control', mode: 'stream' }])
  236. mock.stream('session/control', openStream())
  237. const controller = new AbortController()
  238. onTestFinished(() => { controller.abort() })
  239. const reader = mock.remote.session.control(controller.signal)[Symbol.asyncIterator]()
  240. const failure = new Error('stream disconnected')
  241. const pending = expect(reader.next()).rejects.toBe(failure)
  242. expect(mock.streams.fail('session/control', failure)).toBe(1)
  243. await pending
  244. await mock.streams.drained('session/control')
  245. expect(mock.log.streams('session/control')[0]?.state).toBe('failed')
  246. expect(mock.streams.push('session/control', baseline)).toBe(0)
  247. })
  248. })