chatProtocol.test.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { ChatRpcSocket, GroupEventSocket } from '@/api/realtime'
  3. import { setApiCsrfToken } from '@/api/client'
  4. beforeEach(() => { setApiCsrfToken('csrf-test'); vi.stubGlobal('WebSocket', vi.fn()) })
  5. afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
  6. function httpFixture() {
  7. let kind = 'chat', epoch = '', cursor = 0
  8. let stream: ReadableStreamDefaultController<Uint8Array> | undefined
  9. const send = (frame: object) => stream!.enqueue(new TextEncoder().encode('event: frame\ndata: ' + JSON.stringify(frame) + '\n\n'))
  10. const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
  11. const path = String(input)
  12. if (path.endsWith('/capabilities')) return Response.json({ protocolVersion: 1, csrfToken: 'csrf-test' })
  13. if (init?.method === 'DELETE') return new Response(null, { status: 204 })
  14. if (path.endsWith('/channels')) {
  15. const body = JSON.parse(String(init?.body)); kind = body.channel; epoch = body.epoch; cursor = body.cursor
  16. return Response.json({ id: 'channel-1' }, { status: 201 })
  17. }
  18. if (path.endsWith('/events')) {
  19. const body = new ReadableStream<Uint8Array>({ start(controller) {
  20. stream = controller
  21. send(kind === 'chat'
  22. ? { method: 'event', params: { type: 'gateway.ready', payload: {} } }
  23. : { type: 'group.ready', epoch, cursor, heartbeatSeconds: 20 })
  24. init?.signal?.addEventListener('abort', () => { try { controller.close() } catch {} }, { once: true })
  25. } })
  26. return new Response(body, { headers: { 'content-type': 'text/event-stream' } })
  27. }
  28. return Response.json({ state: 'confirmed', response: { result: { total_tokens: 42 } } })
  29. })
  30. vi.stubGlobal('fetch', fetchMock)
  31. return { fetchMock, send }
  32. }
  33. describe('HTTP-only realtime protocols', () => {
  34. it('waits for SSE ready and submits commands through HTTP without opening a WS', async () => {
  35. const f = httpFixture(), socket = new ChatRpcSocket(), events: string[] = []
  36. socket.onEvent(event => events.push(event.type))
  37. await socket.connect()
  38. expect(socket.state).toBe('ready')
  39. const result = await socket.request('session.usage', { session_id: 'runtime-1' }, 5000, 'stable-command')
  40. expect(result).toEqual({ total_tokens: 42 })
  41. const sent = f.fetchMock.mock.calls.find(([path]) => String(path).endsWith('/commands'))![1]!
  42. expect(new Headers(sent.headers).get('Idempotency-Key')).toBe('stable-command')
  43. expect(JSON.parse(String(sent.body))).toMatchObject({ method: 'session.usage', params: { session_id: 'runtime-1' } })
  44. expect(events).toEqual(['gateway.ready'])
  45. expect(WebSocket).not.toHaveBeenCalled()
  46. socket.close()
  47. })
  48. it('preserves the group epoch/cursor on the HTTP channel', async () => {
  49. const f = httpFixture(), socket = new GroupEventSocket()
  50. await socket.connect({ epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', cursor: 12, onEnvelope: () => {}, onReset: () => {} })
  51. const sent = f.fetchMock.mock.calls.find(([path]) => String(path).endsWith('/channels'))![1]!
  52. expect(JSON.parse(String(sent.body))).toEqual({ channel: 'groups', epoch: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', cursor: 12 })
  53. expect(WebSocket).not.toHaveBeenCalled()
  54. socket.close()
  55. })
  56. it.each([401, 404, 405, 410])('does not downgrade after HTTP %s', async status => {
  57. vi.stubGlobal('fetch', vi.fn(async () => Response.json({ error: 'unsupported or unauthorized' }, { status })))
  58. const socket = new ChatRpcSocket()
  59. await expect(socket.connect()).rejects.toThrow()
  60. expect(WebSocket).not.toHaveBeenCalled()
  61. socket.close()
  62. })
  63. })