controller.client.spec.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  4. import {
  5. downloadUrl, SessionLogDownloadController, sessionLogZipFilename,
  6. } from '../src/client/controller.ts'
  7. const SID = 'session-export-controller' as SessionId
  8. afterEach(() => {
  9. vi.restoreAllMocks()
  10. vi.unstubAllGlobals()
  11. })
  12. describe('SessionLogDownloadController', () => {
  13. it('downloads the host ZIP and publishes one shared success state', async () => {
  14. const fetcher = vi.fn(async () => new Response('zip', { status: 200 }))
  15. const save = vi.fn()
  16. const controller = new SessionLogDownloadController(fetcher, save)
  17. await controller.download(SID)
  18. expect(fetcher).toHaveBeenCalledOnce()
  19. const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]
  20. expect(url.pathname).toBe('/api/session.export')
  21. expect(url.searchParams.get('sessionId')).toBe(SID)
  22. expect(url.searchParams.get('includeDescendants')).toBe('true')
  23. expect(init.method).toBe('HEAD')
  24. expect(init.signal).toBeInstanceOf(AbortSignal)
  25. expect(save).toHaveBeenCalledWith(
  26. url.toString(),
  27. 'dsh-session-session-export-controller.zip',
  28. )
  29. expect(controller.store.getSnapshot().bySession[SID]).toEqual({
  30. open: true, status: 'success', error: null,
  31. })
  32. })
  33. it('collapses concurrent gestures and preserves a dismissed dialog', async () => {
  34. const response = Promise.withResolvers<Response>()
  35. const fetcher = vi.fn(() => response.promise)
  36. const controller = new SessionLogDownloadController(fetcher, vi.fn())
  37. const first = controller.download(SID)
  38. const second = controller.download(SID)
  39. expect(first).toBe(second)
  40. controller.dismiss(SID)
  41. response.resolve(new Response('zip', { status: 200 }))
  42. await first
  43. expect(fetcher).toHaveBeenCalledOnce()
  44. expect(controller.store.getSnapshot().bySession[SID]?.open).toBe(false)
  45. controller.dismiss(SID)
  46. })
  47. it('publishes HTTP and transport failures without leaking rejections', async () => {
  48. const http = new SessionLogDownloadController(
  49. async () => new Response('backend unavailable', { status: 500 }), vi.fn(),
  50. )
  51. await http.download(SID)
  52. expect(http.store.getSnapshot().bySession[SID]).toEqual({
  53. open: true,
  54. status: 'error',
  55. error: 'Export failed: HTTP 500 backend unavailable',
  56. })
  57. const transport = new SessionLogDownloadController(async () => { throw 'offline' }, vi.fn())
  58. await transport.download(SID)
  59. expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('offline')
  60. transport.dismiss('absent' as SessionId)
  61. const emptyDetail = new SessionLogDownloadController(
  62. async () => ({
  63. ok: false, status: 503, text: async () => { throw new Error('body unavailable') },
  64. }) as unknown as Response,
  65. vi.fn(),
  66. )
  67. await emptyDetail.download(SID)
  68. expect(emptyDetail.store.getSnapshot().bySession[SID]?.error).toBe('Export failed: HTTP 503')
  69. })
  70. it('aborts active fetches on disposal and rejects later requests', async () => {
  71. let signal: AbortSignal | undefined
  72. const fetcher = vi.fn((_input: string | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
  73. signal = init?.signal ?? undefined
  74. signal?.addEventListener('abort', () => {
  75. reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
  76. }, { once: true })
  77. }))
  78. const controller = new SessionLogDownloadController(fetcher, vi.fn())
  79. const pending = controller.download(SID)
  80. await controller.dispose()
  81. await expect(pending).resolves.toBeUndefined()
  82. expect(signal?.aborted).toBe(true)
  83. await expect(controller.download(SID)).resolves.toBeUndefined()
  84. await controller.dispose()
  85. })
  86. it('uses the null-origin fallback and default browser operations', async () => {
  87. vi.stubGlobal('location', { origin: 'null' })
  88. const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip'))
  89. vi.stubGlobal('fetch', fetcher)
  90. const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
  91. const controller = new SessionLogDownloadController()
  92. await controller.download(SID)
  93. expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal')
  94. expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'HEAD' })
  95. expect(click).toHaveBeenCalledOnce()
  96. })
  97. it('defaults dialog openness when state is externally cleared before settlement', async () => {
  98. const success = Promise.withResolvers<Response>()
  99. const successful = new SessionLogDownloadController(() => success.promise, vi.fn())
  100. const successRun = successful.download(SID)
  101. successful.store.set({ bySession: {} })
  102. success.resolve(new Response('zip'))
  103. await successRun
  104. expect(successful.store.getSnapshot().bySession[SID]?.open).toBe(true)
  105. const failure = Promise.withResolvers<Response>()
  106. const failing = new SessionLogDownloadController(() => failure.promise, vi.fn())
  107. const failureRun = failing.download(SID)
  108. failing.store.set({ bySession: {} })
  109. failure.reject(new Error('failed after clear'))
  110. await failureRun
  111. expect(failing.store.getSnapshot().bySession[SID]?.open).toBe(true)
  112. })
  113. })
  114. describe('browser download helpers', () => {
  115. it('sanitizes the archive filename and hands the URL to a download anchor', () => {
  116. const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
  117. expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip')
  118. downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip')
  119. expect(click).toHaveBeenCalledOnce()
  120. const anchor = click.mock.instances[0] as HTMLAnchorElement
  121. expect(anchor.href).toBe('http://host/api/session.export?sessionId=a')
  122. expect(anchor.download).toBe('archive.zip')
  123. })
  124. })