ordinaryTranscriptReplay.test.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  1. // @vitest-environment node
  2. import Koa from 'koa'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import type { Server } from 'node:http'
  7. import { afterEach, expect, it, vi } from 'vitest'
  8. import { ChatCacheStore, type ChatCacheCoordinator } from '../../src/server/chatCache'
  9. import { chatTranscriptRouter } from '../../src/server/chatTranscriptApi'
  10. import type { LocalAuthStore } from '../../src/server/localAuth'
  11. import { ChatTranscriptClient } from '../../src/client/api/chatTranscript'
  12. import type { TranscriptSnapshot } from '../../src/shared/chatTranscript'
  13. const scope = ['owner', 'p', 's'] as const
  14. const fixtures: Array<{ home: string; store: ChatCacheStore; server: Server }> = []
  15. afterEach(async () => {
  16. vi.unstubAllGlobals()
  17. for (const { home, store, server } of fixtures.splice(0)) {
  18. store.close()
  19. server.closeAllConnections()
  20. await new Promise<void>(resolve => server.close(() => resolve()))
  21. rmSync(home, { recursive: true, force: true })
  22. }
  23. })
  24. it.each([300, 1000])('replays %i committed events through the HTTP router before confirming the cursor', async count => {
  25. const home = mkdtempSync(join(tmpdir(), 'ordinary-replay-')), store = new ChatCacheStore(home)
  26. store.recordCommand(...scope, 'session.create', { source: 'web' })
  27. store.transcripts.seed(...scope)
  28. const base = store.transcripts.snapshot(...scope, {}, 'current')
  29. for (let i = 0; i < count; i++) {
  30. store.transcripts.upsert(...scope, { id: `m${i}`, role: 'assistant', content: `消息${i}`, status: 'complete' }, i)
  31. }
  32. const finalCursor = store.transcripts.cursor(...scope)
  33. const auth = {
  34. require: () => ({ id: scope[0] }), current: () => ({ id: scope[0] }),
  35. canUseSource: () => true, isUserActive: () => true, pushAuthorizationVersion: () => 1,
  36. } as unknown as LocalAuthStore
  37. const router = chatTranscriptRouter({ store, schedule: () => {} } as unknown as ChatCacheCoordinator, auth)
  38. const app = new Koa().use(router.routes()).use(router.allowedMethods())
  39. const server = app.listen(0, '127.0.0.1')
  40. fixtures.push({ home, store, server })
  41. await new Promise<void>(resolve => server.once('listening', resolve))
  42. const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`
  43. const nativeFetch = globalThis.fetch
  44. const requested: string[] = []
  45. vi.stubGlobal('fetch', (input: string | URL | Request, init?: RequestInit) => {
  46. requested.push(String(input))
  47. return nativeFetch(new URL(String(input), origin), init)
  48. })
  49. let checkpoint: TranscriptSnapshot = base, commits = 0
  50. const failed = vi.fn()
  51. const client = new ChatTranscriptClient('p', 's', async value => { checkpoint = value; commits++ }, failed)
  52. client.restore(base)
  53. const running = client.run()
  54. try {
  55. // Restored checkpoint, every event, then ready; no snapshot repair is necessary.
  56. await vi.waitFor(() => expect(commits).toBe(count + 2), { timeout: 5000 })
  57. expect(checkpoint.cursor).toBe(finalCursor)
  58. expect(checkpoint.messages).toEqual(store.transcripts.all(...scope))
  59. expect(checkpoint.messages).toHaveLength(count)
  60. expect(failed).not.toHaveBeenCalled()
  61. expect(requested).toEqual(['/api/app/chat/sessions/s/events?profile=p'])
  62. store.transcripts.upsert(...scope, { id: 'live', role: 'assistant', content: '重连后新增', status: 'complete' }, count)
  63. await vi.waitFor(() => expect(checkpoint.messages).toHaveLength(count + 1))
  64. expect(checkpoint.messages.at(-1)?.content).toBe('重连后新增')
  65. expect(failed).not.toHaveBeenCalled()
  66. } finally {
  67. client.close()
  68. await running
  69. }
  70. }, 15_000)