ordinaryTranscriptApi.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. // @vitest-environment node
  2. import { createServer, type Server } from 'node:http'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import request from 'supertest'
  7. import { afterEach, describe, expect, it } from 'vitest'
  8. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  9. import type { ServerConfig } from '../../src/server/config'
  10. import { SSEParser } from '../../src/shared/sse'
  11. import {
  12. applyTranscriptEvent,
  13. CHAT_TRANSCRIPT_FEATURE,
  14. type TranscriptEvent,
  15. type TranscriptMessage,
  16. } from '../../src/shared/chatTranscript'
  17. const fixtures: Array<{ home: string; runtime: ApplicationRuntime; server: Server }> = []
  18. afterEach(async () => {
  19. for (const f of fixtures.splice(0)) {
  20. f.runtime.close()
  21. f.server.closeAllConnections()
  22. await new Promise<void>((resolve) => f.server.close(() => resolve()))
  23. rmSync(f.home, { recursive: true, force: true })
  24. }
  25. })
  26. describe('ordinary v2 HTTP snapshot and durable SSE integration', () => {
  27. it('replays the exact committed snapshot and rejects a legacy ordinary channel', async () => {
  28. const home = mkdtempSync(join(tmpdir(), 'ordinary-v2-api-'))
  29. const server = createServer()
  30. await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
  31. const port = (server.address() as { port: number }).port
  32. const config: ServerConfig = {
  33. host: '127.0.0.1',
  34. port,
  35. upstream: new URL('http://127.0.0.1:9119'),
  36. allowedHosts: new Set(),
  37. home,
  38. mediaRoot: home,
  39. attachmentsRoot: home,
  40. imagesRoot: home,
  41. mediaOwner: 'test',
  42. allowInsecureLan: false,
  43. insecureLan: false,
  44. production: false,
  45. }
  46. const runtime = createApplication({
  47. config,
  48. fetchImpl: async (input) => {
  49. const path = new URL(String(input)).pathname
  50. return path === '/api/status'
  51. ? Response.json({ auth_required: false })
  52. : path === '/'
  53. ? new Response(
  54. '<script>window.__HERMES_SESSION_TOKEN__="fixture-token-1234567890123456";</script>',
  55. )
  56. : path === '/api/profiles'
  57. ? Response.json({ profiles: [{ name: 'default', is_default: true }] })
  58. : Response.json({})
  59. },
  60. })
  61. server.on('request', runtime.app.callback())
  62. fixtures.push({ home, runtime, server })
  63. const host = `127.0.0.1:${port}`,
  64. agent = request.agent(server)
  65. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  66. const setup = await agent
  67. .post('/api/app/setup')
  68. .set('Host', host)
  69. .set('Origin', `http://${host}`)
  70. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  71. .send({ username: 'owner', password: 'fixture-password' })
  72. .expect(200)
  73. const owner = String(setup.body.user.id),
  74. store = runtime.chatCache.store
  75. const capabilities = await agent.get('/api/realtime/capabilities').set('Host', host).expect(200)
  76. expect(capabilities.body.features).toContain(CHAT_TRANSCRIPT_FEATURE)
  77. await agent
  78. .post('/api/realtime/channels')
  79. .set('Host', host)
  80. .set('Origin', `http://${host}`)
  81. .set('X-CSRF-Token', capabilities.body.csrfToken)
  82. .send({ channel: 'chat' })
  83. .expect(426)
  84. store.recordCommand(owner, 'default', 's', 'session.create', { source: 'web' })
  85. const base = (
  86. await agent.get('/api/app/chat/sessions/s/snapshot?profile=default').set('Host', host).expect(200)
  87. ).body
  88. const cookies = new Map<string, string>()
  89. for (const response of [bootstrap, setup, capabilities])
  90. for (const value of (response.headers['set-cookie'] ?? []) as unknown as string[]) {
  91. const pair = value.split(';')[0]!
  92. cookies.set(pair.split('=')[0]!, pair)
  93. }
  94. const controller = new AbortController()
  95. const stream = await fetch(`http://127.0.0.1:${port}/api/app/chat/sessions/s/events?profile=default`, {
  96. headers: {
  97. Host: host,
  98. Cookie: [...cookies.values()].join('; '),
  99. 'Last-Event-ID': `${base.epoch}:${base.cursor}`,
  100. },
  101. signal: controller.signal,
  102. })
  103. expect(stream.status).toBe(200)
  104. const reader = stream.body!.getReader(),
  105. parser = new SSEParser(),
  106. decoder = new TextDecoder()
  107. const handshake = await reader.read()
  108. expect(handshake.done).toBe(false)
  109. const readyFrame = parser.feed(decoder.decode(handshake.value!, { stream: true }))
  110. .find(frame => frame.event === 'ready')
  111. expect(readyFrame).toBeTruthy()
  112. expect(JSON.parse(readyFrame!.data)).toMatchObject({
  113. epoch: base.epoch,
  114. cursor: base.cursor,
  115. running: false,
  116. queued: false,
  117. pendingApproval: null,
  118. pendingClarification: null,
  119. })
  120. let seq = 0
  121. for (const [type, payload] of [
  122. ['message.start', {}],
  123. ['message.delta', { text: '完成' }],
  124. ['message.interim', { text: '完成', already_streamed: true }],
  125. ['message.complete', { text: '完成', response_previewed: true }],
  126. ] as Array<[string, object]>)
  127. runtime.realtime.broker.onNativeEvent(owner, 'default', 's', {
  128. type,
  129. payload,
  130. session_id: 'runtime',
  131. seq: ++seq,
  132. epoch: 'native-epoch',
  133. delivery_id: `native:${seq}`,
  134. })
  135. const final = (
  136. await agent.get('/api/app/chat/sessions/s/snapshot?profile=default').set('Host', host).expect(200)
  137. ).body
  138. let rows: TranscriptMessage[] = base.messages,
  139. cursor = base.cursor
  140. try {
  141. while (cursor < final.cursor) {
  142. const chunk = await reader.read()
  143. expect(chunk.done).toBe(false)
  144. for (const frame of parser.feed(decoder.decode(chunk.value, { stream: true }))) {
  145. if (frame.event !== 'transcript') continue
  146. const event = JSON.parse(frame.data) as TranscriptEvent
  147. expect(event.epoch).toBe(base.epoch)
  148. expect(event.previousCursor).toBe(cursor)
  149. rows = applyTranscriptEvent(rows, event)
  150. cursor = event.cursor
  151. }
  152. }
  153. } finally {
  154. controller.abort()
  155. await reader.cancel().catch(() => {})
  156. }
  157. expect(rows).toEqual(final.messages)
  158. expect(rows).toHaveLength(1)
  159. expect(rows[0]).toMatchObject({ content: '完成', status: 'complete', final_result: true })
  160. expect(store.db.prepare('SELECT COUNT(*) n FROM chat_messages').get()!.n).toBe(0)
  161. })
  162. })