detached.spec.ts 2.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
  1. import { describe, expect, it } from 'vitest'
  2. import { createDetachedRuns } from '@deepseek-ai/dsh-hook-protocol'
  3. /** A promise settled from outside, so a test controls exactly when a tracked run finishes. */
  4. function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
  5. let resolve!: () => void
  6. let reject!: (error: Error) => void
  7. const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
  8. return { promise, resolve, reject }
  9. }
  10. describe('createDetachedRuns', () => {
  11. it('starts with an unfired signal; drain fires it (so still-running hook processes get killed)', async () => {
  12. const detached = createDetachedRuns()
  13. expect(detached.signal.aborted).toBe(false)
  14. await detached.drain()
  15. expect(detached.signal.aborted).toBe(true)
  16. expect(String(detached.signal.reason)).toContain('hook bridge disposed')
  17. })
  18. it('drain with nothing tracked resolves immediately', async () => {
  19. await expect(createDetachedRuns().drain()).resolves.toBeUndefined()
  20. })
  21. it('drain waits for a tracked run to settle', async () => {
  22. const detached = createDetachedRuns()
  23. const run = deferred()
  24. detached.track(run.promise)
  25. let drained = false
  26. const draining = detached.drain().then(() => { drained = true })
  27. // Give the drain every chance to (wrongly) resolve before the run settles.
  28. await new Promise(resolve => setTimeout(resolve, 10))
  29. expect(drained).toBe(false)
  30. run.resolve()
  31. await draining
  32. expect(drained).toBe(true)
  33. })
  34. it('drain waits for a run tracked WHILE a prior wave was settling', async () => {
  35. const detached = createDetachedRuns()
  36. const first = deferred()
  37. const second = deferred()
  38. detached.track(first.promise)
  39. // The late run enters the registry from the first run's own continuation —
  40. // after drain() snapshotted its first wave.
  41. void first.promise.then(() => { detached.track(second.promise) })
  42. let drained = false
  43. const draining = detached.drain().then(() => { drained = true })
  44. first.resolve()
  45. await new Promise(resolve => setTimeout(resolve, 10))
  46. expect(drained).toBe(false)
  47. second.resolve()
  48. await draining
  49. expect(drained).toBe(true)
  50. })
  51. it('a rejected tracked run is absorbed by the settlement bookkeeping (drain still resolves)', async () => {
  52. const detached = createDetachedRuns()
  53. const run = deferred()
  54. detached.track(run.promise)
  55. // The caller-side handler every bridge attaches; the tracker's own
  56. // bookkeeping must not depend on it, but an UNHANDLED rejection would fail
  57. // the test run, which is exactly the guarantee under test.
  58. run.promise.catch(() => {})
  59. run.reject(new Error('hook run boom'))
  60. await expect(detached.drain()).resolves.toBeUndefined()
  61. })
  62. })