present-open.client.spec.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. /** Delivery gestures share pending state, report failures, and cancel with the plugin. */
  2. import { afterEach, expect, it, vi } from 'vitest'
  3. import { SessionId } from '@deepseek-ai/dsh-session/types'
  4. import { PresentedOpenController } from '../src/client/present-open.ts'
  5. afterEach(() => { vi.unstubAllGlobals() })
  6. const id = SessionId('fork')
  7. const url = '/api/present.open?sessionId=fork&seq=2&index=1'
  8. it('coalesces concurrent card and mention gestures, then allows another open', async () => {
  9. const reply = Promise.withResolvers<Response>()
  10. const fetcher = vi.fn().mockReturnValue(reply.promise)
  11. vi.stubGlobal('fetch', fetcher)
  12. const controller = new PresentedOpenController()
  13. const first = controller.open(id, 2, 1)
  14. await controller.open(id, 2, 1)
  15. expect(fetcher).toHaveBeenCalledTimes(1)
  16. expect(fetcher).toHaveBeenCalledWith(url, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal })
  17. expect(controller.state.getSnapshot()[url]).toBe('opening')
  18. reply.resolve(new Response(null, { status: 204 }))
  19. await first
  20. expect(controller.state.getSnapshot()[url]).toBe('opened')
  21. await controller.open(id, 2, 1)
  22. expect(fetcher).toHaveBeenCalledTimes(2)
  23. await controller.dispose()
  24. })
  25. it('opens changed files through their own coordinates', async () => {
  26. const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>().mockResolvedValue(new Response(null, { status: 204 }))
  27. vi.stubGlobal('fetch', fetcher)
  28. const controller = new PresentedOpenController()
  29. await controller.openChanged(id, 9, 1)
  30. expect(fetcher.mock.calls.map(call => call[0])).toEqual(['/api/changes.open?sessionId=fork&seq=9&index=1'])
  31. expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened')
  32. fetcher.mockResolvedValueOnce(new Response(null, { status: 422 }))
  33. await controller.openChanged(id, 9, 1)
  34. expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable')
  35. await controller.dispose()
  36. })
  37. it.each(['http', 'network'])('publishes retryable %s failures', async (failure) => {
  38. const fetcher = vi.fn()
  39. if (failure === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 }))
  40. else fetcher.mockRejectedValueOnce(new Error('offline'))
  41. fetcher.mockResolvedValue(new Response(null, { status: 204 }))
  42. vi.stubGlobal('fetch', fetcher)
  43. const controller = new PresentedOpenController()
  44. await controller.open(id, 2, 1)
  45. expect(controller.state.getSnapshot()[url]).toBe('error')
  46. await controller.open(id, 2, 1)
  47. expect(controller.state.getSnapshot()[url]).toBe('opened')
  48. await controller.dispose()
  49. })
  50. it('awaits cancellation and prevents late state publication or new requests after disposal', async () => {
  51. const aborted = Promise.withResolvers<undefined>()
  52. const release = Promise.withResolvers<Response>()
  53. const fetcher = vi.fn((_url: string, { signal }: RequestInit) => {
  54. signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
  55. return release.promise
  56. })
  57. vi.stubGlobal('fetch', fetcher)
  58. const controller = new PresentedOpenController()
  59. const open = controller.open(id, 2, 1)
  60. const state = controller.state.getSnapshot()
  61. let disposed = false
  62. const disposal = controller.dispose().then(() => { disposed = true })
  63. await aborted.promise
  64. expect(disposed).toBe(false)
  65. release.resolve(new Response(null, { status: 204 }))
  66. await Promise.all([open, disposal])
  67. expect(controller.state.getSnapshot()).toBe(state)
  68. await controller.open(id, 2, 1)
  69. expect(fetcher).toHaveBeenCalledOnce()
  70. })
  71. it('shares pending state across open and reveal and retries the selected action', async () => {
  72. const reply = Promise.withResolvers<Response>()
  73. const fetcher = vi.fn().mockReturnValueOnce(reply.promise).mockResolvedValue(new Response(null, { status: 204 }))
  74. vi.stubGlobal('fetch', fetcher)
  75. const controller = new PresentedOpenController()
  76. const revealing = controller.open(id, 2, 1, 'reveal')
  77. await controller.open(id, 2, 1)
  78. expect(controller.state.getSnapshot()[url]).toBe('revealing')
  79. expect(fetcher).toHaveBeenCalledOnce()
  80. expect(fetcher.mock.calls[0]?.[0]).toBe(`${url}&action=reveal`)
  81. reply.resolve(new Response(null, { status: 500 }))
  82. await revealing
  83. expect(controller.state.getSnapshot()[url]).toBe('revealError')
  84. await controller.open(id, 2, 1, 'reveal')
  85. expect(controller.state.getSnapshot()[url]).toBe('revealed')
  86. await controller.dispose()
  87. })
  88. it.each([null, {}, { name: 'host', available: 'yes', fileManager: 'finder' },
  89. { name: 'host', available: true, fileManager: 'unknown' }, 'invalid json', 'http', 'network',
  90. ])('makes invalid Host metadata retryable: %j', async (value) => {
  91. const host = { name: 'linux-host', available: true, fileManager: 'directory' }
  92. const fetcher = vi.fn()
  93. if (value === 'network') fetcher.mockRejectedValueOnce(new Error('offline'))
  94. else if (value === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 }))
  95. else if (value === 'invalid json') fetcher.mockResolvedValueOnce(new Response('bad JSON'))
  96. else fetcher.mockResolvedValueOnce(Response.json(value))
  97. fetcher.mockResolvedValueOnce(Response.json(host))
  98. vi.stubGlobal('fetch', fetcher)
  99. const controller = new PresentedOpenController()
  100. await controller.loadHost()
  101. expect(controller.host.getSnapshot()).toBe('error')
  102. await controller.loadHost()
  103. expect(controller.host.getSnapshot()).toEqual(host)
  104. await controller.dispose()
  105. await controller.loadHost()
  106. expect(fetcher).toHaveBeenCalledTimes(2)
  107. })
  108. it('coalesces metadata reads and suppresses their publication after disposal', async () => {
  109. const reply = Promise.withResolvers<Response>()
  110. const fetcher = vi.fn().mockReturnValue(reply.promise)
  111. vi.stubGlobal('fetch', fetcher)
  112. const controller = new PresentedOpenController()
  113. const first = controller.loadHost()
  114. const second = controller.loadHost()
  115. expect(fetcher).toHaveBeenCalledOnce()
  116. const disposal = controller.dispose()
  117. expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true)
  118. reply.resolve(Response.json({ name: 'host', available: false, fileManager: null }))
  119. await Promise.all([first, second, disposal])
  120. expect(controller.host.getSnapshot()).toBeNull()
  121. })
  122. it('invalidates cached desktop metadata without eagerly fetching an unused Host', async () => {
  123. const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'old', available: false, fileManager: null }))
  124. vi.stubGlobal('fetch', fetcher)
  125. const controller = new PresentedOpenController()
  126. await controller.loadHost()
  127. controller.resetHost()
  128. expect(controller.host.getSnapshot()).toBeNull()
  129. expect(fetcher).toHaveBeenCalledOnce()
  130. fetcher.mockResolvedValue(Response.json({ name: 'new', available: true, fileManager: 'finder' }))
  131. await controller.loadHost()
  132. expect(controller.host.getSnapshot()).toMatchObject({ name: 'new', available: true })
  133. await controller.dispose()
  134. })
  135. it('discards a replaced Host response and keeps the new metadata request coalesced', async () => {
  136. const oldReply = Promise.withResolvers<Response>()
  137. const newReply = Promise.withResolvers<Response>()
  138. const fetcher = vi.fn().mockReturnValueOnce(oldReply.promise).mockReturnValue(newReply.promise)
  139. vi.stubGlobal('fetch', fetcher)
  140. const controller = new PresentedOpenController()
  141. const oldLoad = controller.loadHost()
  142. controller.resetHost()
  143. expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true)
  144. const newLoad = controller.loadHost()
  145. oldReply.resolve(Response.json({ name: 'old', available: false, fileManager: null }))
  146. await oldLoad
  147. expect(controller.host.getSnapshot()).toBeNull()
  148. const coalesced = controller.loadHost()
  149. expect(fetcher).toHaveBeenCalledTimes(2)
  150. newReply.resolve(Response.json({ name: 'new', available: true, fileManager: 'finder' }))
  151. await Promise.all([newLoad, coalesced])
  152. expect(controller.host.getSnapshot()).toMatchObject({ name: 'new' })
  153. await controller.dispose()
  154. })
  155. it.each(['open', 'reveal'] as const)('reports an unavailable Host path for %s while retaining the declaration', async (action) => {
  156. vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 422 })))
  157. const controller = new PresentedOpenController()
  158. await controller.open(id, 2, 1, action)
  159. expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable')
  160. await controller.dispose()
  161. })