chatProtocol.test.ts 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { ChatRpcSocket, GroupEventSocket } from '@/api/realtime'
  3. import { setApiCsrfToken } from '@/api/client'
  4. class FakeWebSocket extends EventTarget {
  5. static readonly CONNECTING = 0
  6. static readonly OPEN = 1
  7. static readonly CLOSING = 2
  8. static readonly CLOSED = 3
  9. static instances: FakeWebSocket[] = []
  10. readonly url: string
  11. readyState = FakeWebSocket.CONNECTING
  12. sent: string[] = []
  13. constructor(url: string) {
  14. super()
  15. this.url = url
  16. FakeWebSocket.instances.push(this)
  17. queueMicrotask(() => {
  18. this.readyState = FakeWebSocket.OPEN
  19. this.dispatchEvent(new Event('open'))
  20. })
  21. }
  22. send(data: string): void { this.sent.push(data) }
  23. close(code = 1000, reason = ''): void {
  24. this.readyState = FakeWebSocket.CLOSED
  25. this.dispatchEvent(new CloseEvent('close', { code, reason }))
  26. }
  27. message(value: unknown): void { this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(value) })) }
  28. }
  29. beforeEach(() => {
  30. FakeWebSocket.instances = []
  31. setApiCsrfToken('csrf-test')
  32. vi.stubGlobal('WebSocket', FakeWebSocket)
  33. })
  34. afterEach(() => {
  35. vi.unstubAllGlobals()
  36. vi.restoreAllMocks()
  37. })
  38. function leaseFetch() {
  39. const mock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ lease: 'lease-1' }), {
  40. status: 201, headers: { 'content-type': 'application/json' },
  41. }))
  42. vi.stubGlobal('fetch', mock)
  43. return mock
  44. }
  45. describe('raw realtime protocols', () => {
  46. it('waits for gateway.ready and correlates JSON-RPC responses', async () => {
  47. leaseFetch()
  48. const socket = new ChatRpcSocket()
  49. const events: string[] = []
  50. socket.onEvent(event => events.push(event.type))
  51. const connecting = socket.connect()
  52. await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1))
  53. const transport = FakeWebSocket.instances[0]
  54. await Promise.resolve()
  55. expect(socket.state).toBe('connected')
  56. transport.message({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } })
  57. await connecting
  58. expect(socket.state).toBe('ready')
  59. const response = socket.request('session.usage', { session_id: 'runtime-1' })
  60. const frame = JSON.parse(transport.sent[0])
  61. expect(frame).toMatchObject({ jsonrpc: '2.0', method: 'session.usage', params: { session_id: 'runtime-1' } })
  62. transport.message({ jsonrpc: '2.0', id: frame.id, result: { total_tokens: 42 } })
  63. await expect(response).resolves.toEqual({ total_tokens: 42 })
  64. expect(events).toEqual(['gateway.ready'])
  65. socket.close()
  66. })
  67. it('binds a group lease to epoch/cursor but exposes only the lease in WS query', async () => {
  68. const fetchMock = leaseFetch()
  69. const socket = new GroupEventSocket()
  70. await socket.connect({
  71. epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
  72. cursor: 12,
  73. onEnvelope: () => undefined,
  74. onReset: () => undefined,
  75. })
  76. const request = fetchMock.mock.calls[0]?.[1]
  77. expect(request).toBeDefined()
  78. expect(JSON.parse(String(request!.body))).toEqual({
  79. channel: 'groups', epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', cursor: 12,
  80. })
  81. const url = new URL(FakeWebSocket.instances[0].url)
  82. expect([...url.searchParams.keys()]).toEqual(['lease'])
  83. socket.close()
  84. })
  85. })