ordinaryTranscriptV2.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import { beforeEach, describe, expect, it, vi } from 'vitest'
  2. import fixture from '../fixtures/ordinary-chat-v2/protocol.json'
  3. import { ChatTranscriptClient } from '@/api/chatTranscript'
  4. import type { TranscriptSnapshot } from '@shared/chatTranscript'
  5. const api = vi.hoisted(() => ({ request: vi.fn() }))
  6. vi.mock('@/api/client', () => ({ apiRequest: api.request }))
  7. describe('ordinary v2 durable Web replica', () => {
  8. beforeEach(() => {
  9. api.request.mockReset()
  10. })
  11. function setup() {
  12. const changed = vi.fn().mockResolvedValue(undefined),
  13. client = new ChatTranscriptClient('p', 's', changed, vi.fn()) as any
  14. client.restore(structuredClone(fixture.snapshot))
  15. return { client, changed }
  16. }
  17. it('converges on the common fixture and does not resurrect a deletion from a delayed page', async () => {
  18. const { client, changed } = setup()
  19. let release!: (value: unknown) => void
  20. api.request.mockImplementation(
  21. () =>
  22. new Promise((resolve) => {
  23. release = resolve
  24. }),
  25. )
  26. const older = client.loadOlder()
  27. for (const event of fixture.events) await client.receive(event)
  28. release(fixture.stalePage)
  29. await older
  30. expect(changed.mock.lastCall![0]).toMatchObject({
  31. cursor: 4,
  32. messages: fixture.expectedMessages,
  33. deletedIds: ['old-preview'],
  34. })
  35. })
  36. it('does not advance messages or cursor when persistence fails, and can retry exactly once', async () => {
  37. const { client, changed } = setup()
  38. changed.mockRejectedValueOnce(new Error('disk full'))
  39. await expect(client.receive(fixture.events[0])).rejects.toThrow('disk full')
  40. expect(client.snapshot).toEqual(fixture.snapshot)
  41. await client.receive(fixture.events[0])
  42. await client.receive(fixture.events[0])
  43. expect(client.snapshot.messages[0].content).toBe('前半段后半段')
  44. expect(client.snapshot.cursor).toBe(2)
  45. })
  46. it('rejects gaps and old generations without corrupting its checkpoint', async () => {
  47. const { client } = setup()
  48. await expect(client.receive(fixture.events[2])).rejects.toThrow('版本不连续')
  49. await expect(client.receive({ ...fixture.events[0], epoch: 'old' })).rejects.toThrow('版本不连续')
  50. expect(client.snapshot).toEqual(fixture.snapshot)
  51. })
  52. it('discards v1 checkpoints rather than merging them into v2', () => {
  53. const client = new ChatTranscriptClient(
  54. 'p',
  55. 's',
  56. async () => {},
  57. () => {},
  58. ) as any
  59. client.restore({
  60. ...fixture.snapshot,
  61. protocol: 'ordinary-chat-transcript-v1',
  62. } as unknown as TranscriptSnapshot)
  63. expect(client.snapshot).toBeUndefined()
  64. })
  65. it('rejects an ahead control cursor and ignores a delayed control without replacing state', async () => {
  66. const { client, changed } = setup()
  67. const saved = { ...client.snapshot, running: true, queued: true, liveStatus: '进行中' }
  68. client.restore(saved)
  69. await expect(client.control({ epoch: saved.epoch, cursor: saved.cursor + 1, running: false, queued: false })).rejects.toThrow('版本不连续')
  70. await client.control({ epoch: saved.epoch, cursor: saved.cursor - 1, running: false, queued: false })
  71. expect(changed).not.toHaveBeenCalled()
  72. expect(client.snapshot).toEqual(saved)
  73. client.close()
  74. })
  75. it.each(['ready', 'state'])('repairs a %s cursor gap with a snapshot instead of skipping messages', async event => {
  76. vi.useFakeTimers()
  77. const { client, changed } = setup()
  78. const repaired = { ...fixture.snapshot, cursor: 3, hasOlder: false,
  79. messages: [{ ...fixture.snapshot.messages[0], revision: 3, content: '补齐的消息' }] }
  80. api.request.mockResolvedValue(repaired)
  81. let attempts = 0
  82. vi.stubGlobal('fetch', vi.fn((_url: unknown, options: RequestInit) => {
  83. attempts++
  84. return Promise.resolve(new Response(new ReadableStream<Uint8Array>({ start(controller) {
  85. if (attempts === 1) controller.enqueue(new TextEncoder().encode(`event: ${event}\ndata: ${JSON.stringify({ epoch: repaired.epoch, cursor: 3, running: false, queued: false })}\n\n`))
  86. options.signal?.addEventListener('abort', () => { try { controller.close() } catch {} }, { once: true })
  87. } }), { status: 200 }))
  88. }))
  89. const running = client.run()
  90. try {
  91. await vi.advanceTimersByTimeAsync(1000)
  92. expect(api.request).toHaveBeenCalledWith(expect.stringContaining('/snapshot?'), expect.anything())
  93. expect(changed.mock.lastCall![0]).toMatchObject({ cursor: 3, messages: repaired.messages })
  94. expect(attempts).toBe(2)
  95. } finally {
  96. client.close(); await running; vi.unstubAllGlobals(); vi.useRealTimers()
  97. }
  98. })
  99. it('reopens an inactive event connection without dropping the durable cursor', async () => {
  100. vi.useFakeTimers()
  101. const { client } = setup()
  102. const fetchMock = vi.fn(
  103. (_url: unknown, options: RequestInit) =>
  104. new Promise((_resolve, reject) => {
  105. options.signal?.addEventListener('abort', () => reject(new Error('inactive stream')), {
  106. once: true,
  107. })
  108. }),
  109. )
  110. vi.stubGlobal('fetch', fetchMock)
  111. const running = client.run()
  112. try {
  113. await vi.advanceTimersByTimeAsync(46_000)
  114. expect(fetchMock).toHaveBeenCalledTimes(2)
  115. expect(client.snapshot.cursor).toBe(1)
  116. expect(fetchMock.mock.calls[1]![1].headers).toMatchObject({ 'Last-Event-ID': 'fixture-epoch:1' })
  117. } finally {
  118. client.close()
  119. await running
  120. vi.unstubAllGlobals()
  121. vi.useRealTimers()
  122. }
  123. })
  124. it('restores running state and interactions before resuming the saved event cursor', async () => {
  125. const { client, changed } = setup()
  126. const saved = { ...fixture.snapshot, running: true, liveStatus: '正在思考', pendingApproval: { id: 'approval' } }
  127. client.restore(saved)
  128. const fetchMock = vi.fn((_url: unknown, options: RequestInit) => {
  129. expect(changed).toHaveBeenCalledWith(saved)
  130. return new Promise((_resolve, reject) => {
  131. options.signal?.addEventListener('abort', () => reject(new Error('closed')), { once: true })
  132. })
  133. })
  134. vi.stubGlobal('fetch', fetchMock)
  135. const running = client.run()
  136. try {
  137. await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
  138. expect(api.request).not.toHaveBeenCalled()
  139. expect(fetchMock.mock.calls[0]![1].headers).toMatchObject({ 'Last-Event-ID': 'fixture-epoch:1' })
  140. } finally {
  141. client.close()
  142. await running
  143. vi.unstubAllGlobals()
  144. }
  145. })
  146. it('reconciles a stale running checkpoint from the authoritative SSE ready state', async () => {
  147. const { client, changed } = setup()
  148. const saved = { ...fixture.snapshot, running: true, queued: false, liveStatus: '正在思考' }
  149. client.restore(saved)
  150. const fetchMock = vi.fn((_url: unknown, options: RequestInit) => {
  151. const stream = new ReadableStream<Uint8Array>({
  152. start(controller) {
  153. const frame = `event: ready\ndata: ${JSON.stringify({
  154. epoch: 'fixture-epoch',
  155. cursor: fixture.snapshot.cursor,
  156. running: false,
  157. queued: false,
  158. pendingApproval: null,
  159. pendingClarification: null,
  160. liveStatus: null,
  161. error: null,
  162. })}\n\n`
  163. controller.enqueue(new TextEncoder().encode(frame))
  164. options.signal?.addEventListener('abort', () => controller.close(), { once: true })
  165. },
  166. })
  167. return Promise.resolve(new Response(stream, { status: 200 }))
  168. })
  169. vi.stubGlobal('fetch', fetchMock)
  170. const running = client.run()
  171. try {
  172. await vi.waitFor(() => expect(changed.mock.lastCall![0]).toMatchObject({
  173. cursor: fixture.snapshot.cursor,
  174. running: false,
  175. queued: false,
  176. liveStatus: null,
  177. }))
  178. } finally {
  179. client.close()
  180. await running
  181. vi.unstubAllGlobals()
  182. }
  183. })
  184. })