ordinaryTranscriptV2.test.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  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('reopens an inactive event connection without dropping the durable cursor', async () => {
  66. vi.useFakeTimers()
  67. const { client } = setup()
  68. const fetchMock = vi.fn(
  69. (_url: unknown, options: RequestInit) =>
  70. new Promise((_resolve, reject) => {
  71. options.signal?.addEventListener('abort', () => reject(new Error('inactive stream')), {
  72. once: true,
  73. })
  74. }),
  75. )
  76. vi.stubGlobal('fetch', fetchMock)
  77. const running = client.run()
  78. try {
  79. await vi.advanceTimersByTimeAsync(46_000)
  80. expect(fetchMock).toHaveBeenCalledTimes(2)
  81. expect(client.snapshot.cursor).toBe(1)
  82. expect(fetchMock.mock.calls[1]![1].headers).toMatchObject({ 'Last-Event-ID': 'fixture-epoch:1' })
  83. } finally {
  84. client.close()
  85. await running
  86. vi.unstubAllGlobals()
  87. vi.useRealTimers()
  88. }
  89. })
  90. it('restores running state and interactions before resuming the saved event cursor', async () => {
  91. const { client, changed } = setup()
  92. const saved = { ...fixture.snapshot, running: true, liveStatus: '正在思考', pendingApproval: { id: 'approval' } }
  93. client.restore(saved)
  94. const fetchMock = vi.fn((_url: unknown, options: RequestInit) => {
  95. expect(changed).toHaveBeenCalledWith(saved)
  96. return new Promise((_resolve, reject) => {
  97. options.signal?.addEventListener('abort', () => reject(new Error('closed')), { once: true })
  98. })
  99. })
  100. vi.stubGlobal('fetch', fetchMock)
  101. const running = client.run()
  102. try {
  103. await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
  104. expect(api.request).not.toHaveBeenCalled()
  105. expect(fetchMock.mock.calls[0]![1].headers).toMatchObject({ 'Last-Event-ID': 'fixture-epoch:1' })
  106. } finally {
  107. client.close()
  108. await running
  109. vi.unstubAllGlobals()
  110. }
  111. })
  112. })