inbox.spec.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. import { describe, expect, it } from 'vitest'
  2. import { Inbox } from '../src/inbox.ts'
  3. function resolverPair() {
  4. let r!: () => void
  5. const p = new Promise<void>((resolve) => { r = resolve })
  6. return { promise: p, resolve: r }
  7. }
  8. describe('Inbox', () => {
  9. it('enqueues and drains queued messages in FIFO order', () => {
  10. const inbox = new Inbox()
  11. inbox.enqueue({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } })
  12. inbox.enqueue({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } })
  13. expect(inbox.hasQueued).toBe(true)
  14. const drained = inbox.drainQueued()
  15. expect(drained).toHaveLength(2)
  16. expect(drained[0]!.content[0]).toMatchObject({ text: 'first' })
  17. expect(drained[1]!.content[0]).toMatchObject({ text: 'second' })
  18. expect(inbox.hasQueued).toBe(false)
  19. })
  20. it('pushes and drains steering messages separately from queued', () => {
  21. const inbox = new Inbox()
  22. inbox.steer({ content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } })
  23. expect(inbox.hasQueued).toBe(false)
  24. expect(inbox.hasSteering).toBe(true)
  25. const steering = inbox.drainSteering()
  26. expect(steering).toHaveLength(1)
  27. expect(inbox.hasSteering).toBe(false)
  28. })
  29. it('waitForQueued returns immediately when a queued message is already present', async () => {
  30. const inbox = new Inbox()
  31. inbox.enqueue({ content: [{ type: 'text', text: 'ready' }], source: { kind: 'user' } })
  32. const started = Date.now()
  33. await inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel
  34. expect(Date.now() - started).toBeLessThan(50)
  35. })
  36. it('waitForQueued resolves when a message is enqueued', async () => {
  37. const inbox = new Inbox()
  38. const waiter = inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel
  39. // enqueue after starting the wait
  40. setTimeout(() => { inbox.enqueue({ content: [{ type: 'text', text: 'wake' }], source: { kind: 'user' } }) }, 5)
  41. await waiter
  42. })
  43. it('waitForQueued resolves when the cancel promise resolves', async () => {
  44. const inbox = new Inbox()
  45. const { promise, resolve } = resolverPair()
  46. const waiter = inbox.waitForQueued(promise)
  47. resolve()
  48. await waiter
  49. })
  50. it('waitForQueued overwrites the previous wakeup callback (only the latest waiter is notified)', async () => {
  51. const inbox = new Inbox()
  52. const { promise: p1, resolve: r1 } = resolverPair()
  53. void inbox.waitForQueued(new Promise(() => {})) // first call, never resolved
  54. void inbox.waitForQueued(p1) // second call overwrites wakeup
  55. // Cancel p1 (the latest waiter's cancel) — the wakeup was overwritten
  56. // to p1's resolve, so canceling p1 triggers the finally block which
  57. // clears the wakeup if it matches.
  58. r1()
  59. await p1
  60. // Now enqueue: the first waiter's wakeup (which was overwritten) won't
  61. // fire, and the second waiter's wakeup was cleared by cancel.
  62. // The enqueue calls wakeup?.() but wakeup was cleared — no crash, no hang.
  63. inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } })
  64. // The overwrite path + finally cleanup are exercised
  65. })
  66. it('clears wakeup in finally handler when enqueue resolves', async () => {
  67. const inbox = new Inbox()
  68. void inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel
  69. // The wakeup is set. Now trigger it via enqueue → wakeup() calls resolve,
  70. // promise resolves, finally clears wakeup because wakeup === resolve.
  71. inbox.enqueue({ content: [{ type: 'text', text: 'wake' }], source: { kind: 'user' } })
  72. // No explicit await needed — enqueue is synchronous, and the microtask
  73. // (finally) runs. The key coverage hit is finally with wakeup === resolve.
  74. })
  75. it('finally handler does not clear wakeup when a different waiter overwrote it', async () => {
  76. // First waiter's cancel resolves AFTER a second waiter overwrote wakeup.
  77. // First waiter's finally sees wakeup !== its resolve → does not clear.
  78. const inbox = new Inbox()
  79. const { promise: c1, resolve: r1 } = resolverPair()
  80. void inbox.waitForQueued(c1) // wakeup = resolve1, c1.then(resolve1)
  81. void inbox.waitForQueued(new Promise(() => {})) // wakeup = resolve2, cancel never resolves
  82. // Resolve c1 (the first cancel). c1.then(resolve1) fires → resolve1() called
  83. // → waiter1's promise resolves → finally: wakeup === resolve1? NO (it's resolve2)
  84. // → wakeup is NOT cleared.
  85. r1()
  86. await c1
  87. // Now enqueue: wakeup() calls resolve2 → waiter2 resolves
  88. // But waiter2's cancel never resolves — that's fine, enqueue resolves it.
  89. inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } })
  90. // No need to await anything further — enqueue is synchronous wakeup
  91. })
  92. })