retention.client.spec.ts 3.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
  1. /** A window hold accepts each physical acknowledgement and releases pending consumers. */
  2. import { afterEach, expect, it, vi } from 'vitest'
  3. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  4. import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
  5. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  6. import type { TerminalRemote } from '../src/client/model.ts'
  7. import { TerminalWindowHold } from '../src/client/retention.ts'
  8. import type { TerminalRetentionFrame, WebTerminalId } from '../src/types.ts'
  9. const holds: TerminalWindowHold[] = []
  10. afterEach(async () => { await Promise.all(holds.splice(0).map(hold => hold.dispose())) })
  11. function hold(retain: TerminalRemote['retain']) {
  12. const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
  13. const held = new TerminalWindowHold(gateway, { retain } as TerminalRemote, 'session' as SessionId, 'terminal' as WebTerminalId)
  14. holds.push(held)
  15. return held
  16. }
  17. it('fails pending and later attachments when the Host ends an acknowledged hold', async () => {
  18. const finish = Promise.withResolvers<undefined>()
  19. const held = hold(async function* () { yield { type: 'retained' }; await finish.promise })
  20. await held.ready(new AbortController().signal)
  21. finish.resolve(undefined)
  22. await expect.poll(() => held.failed).toBe(true)
  23. await expect(held.ready(new AbortController().signal)).rejects.toThrow('hold ended')
  24. })
  25. it('cancels an acknowledgement waiter independently of another view of the same terminal', async () => {
  26. const ack = Promise.withResolvers<undefined>()
  27. const held = hold(async function* (_session, _id, signal) {
  28. await ack.promise
  29. yield { type: 'retained' }
  30. await new Promise<void>((resolve) => { signal?.addEventListener('abort', () => { resolve() }, { once: true }) })
  31. })
  32. const cancel = new AbortController()
  33. const first = held.ready(cancel.signal)
  34. const rejected = expect(first).rejects.toThrow('view removed')
  35. const second = held.ready(new AbortController().signal)
  36. cancel.abort(new Error('view removed'))
  37. await rejected
  38. ack.resolve(undefined)
  39. await second
  40. expect(held.failed).toBe(false)
  41. })
  42. it('preserves a non-Error transport failure as the cause of the failed hold', async () => {
  43. const gateway = { $stream: () => ({
  44. async *[Symbol.asyncIterator](): AsyncGenerator<never> {
  45. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercise an untyped carrier failure.
  46. await Promise.reject('non-error transport failure')
  47. },
  48. dispose: async () => {},
  49. } as unknown as RemoteStream<TerminalRetentionFrame>) } as Pick<ClientRemote, '$stream'>
  50. const held = new TerminalWindowHold(gateway, { retain: vi.fn() } as unknown as TerminalRemote, 'session' as SessionId, 'terminal' as WebTerminalId)
  51. holds.push(held)
  52. await expect.poll(() => held.failed).toBe(true)
  53. await expect(held.ready(new AbortController().signal)).rejects.toMatchObject({ cause: 'non-error transport failure' })
  54. })