backend-controller.spec.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { DesktopBackendController, type DesktopBackendState } from '../src/backend-controller.ts'
  3. function deferred() {
  4. let resolve!: () => void
  5. let reject!: (error: Error) => void
  6. const promise = new Promise<void>((accept, decline) => { resolve = accept; reject = decline })
  7. return { promise, resolve, reject }
  8. }
  9. function fixture() {
  10. const started = deferred()
  11. const ready = deferred()
  12. const stopping = deferred()
  13. const exited = deferred()
  14. const states: DesktopBackendState[] = []
  15. let fail!: (error: Error) => void
  16. const host = {
  17. start: vi.fn(() => { started.resolve(); return ready.promise }),
  18. stop: vi.fn(() => { stopping.resolve(); return exited.promise }),
  19. }
  20. const create = vi.fn((onFailure: (error: Error) => void) => { fail = onFailure; return host })
  21. const controller = new DesktopBackendController(create, state => states.push(state))
  22. return { controller, host, create, states, started, ready, stopping, exited, fail: (error: Error) => { fail(error) } }
  23. }
  24. describe('desktop backend controller', () => {
  25. it('shares preparation and startup between concurrent retries', async () => {
  26. const f = fixture()
  27. const prepare = vi.fn(async () => {})
  28. const first = f.controller.start(prepare)
  29. expect(f.controller.start(prepare)).toBe(first)
  30. await f.started.promise
  31. expect(f.controller.host).toBeUndefined()
  32. expect(f.states).toEqual([{ phase: 'starting' }])
  33. f.ready.resolve()
  34. await first
  35. expect(f.controller.host).toBe(f.host)
  36. await f.controller.start(prepare)
  37. expect(prepare).toHaveBeenCalledTimes(1)
  38. expect(f.host.start).toHaveBeenCalledTimes(1)
  39. f.exited.resolve()
  40. await f.controller.close()
  41. })
  42. it('waits for pending preparation on close and never spawns afterward', async () => {
  43. const f = fixture()
  44. const preparing = deferred()
  45. const prepared = deferred()
  46. const start = f.controller.start(() => { preparing.resolve(); return prepared.promise })
  47. await preparing.promise
  48. let closed = false
  49. const close = f.controller.close().then(() => { closed = true })
  50. await Promise.resolve()
  51. expect(closed).toBe(false)
  52. prepared.resolve()
  53. await Promise.all([start, close])
  54. expect(f.create).not.toHaveBeenCalled()
  55. expect(f.states).toEqual([{ phase: 'starting' }])
  56. await expect(f.controller.start(async () => {})).rejects.toThrow('closed')
  57. })
  58. it('stops a pending child once and waits for startup and child exit on close', async () => {
  59. const f = fixture()
  60. const start = f.controller.start(async () => {})
  61. const rejected = expect(start).rejects.toThrow('stopped')
  62. await f.started.promise
  63. const close = f.controller.close()
  64. expect(f.controller.close()).toBe(close)
  65. await f.stopping.promise
  66. let closed = false
  67. void close.then(() => { closed = true })
  68. f.ready.reject(new Error('stopped'))
  69. await Promise.resolve()
  70. expect(closed).toBe(false)
  71. f.exited.resolve()
  72. await Promise.all([rejected, close])
  73. expect(f.host.stop).toHaveBeenCalledTimes(1)
  74. expect(f.states).toEqual([{ phase: 'starting' }])
  75. expect(f.controller.host).toBeUndefined()
  76. })
  77. it('publishes preparation errors and permits retry', async () => {
  78. const f = fixture()
  79. await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile')
  80. expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' })
  81. expect(f.create).not.toHaveBeenCalled()
  82. f.ready.resolve()
  83. await f.controller.start(async () => {})
  84. expect(f.controller.state).toEqual({ phase: 'ready' })
  85. f.exited.resolve()
  86. await f.controller.close()
  87. })
  88. it('cleans up a failed startup before rejecting and displays the actual failure', async () => {
  89. const f = fixture()
  90. const start = f.controller.start(async () => {})
  91. const rejected = expect(start).rejects.toThrow('plugin failed')
  92. await f.started.promise
  93. f.ready.reject(new Error('plugin failed'))
  94. await f.stopping.promise
  95. f.exited.resolve()
  96. await rejected
  97. expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' })
  98. expect(f.host.stop).toHaveBeenCalledTimes(1)
  99. await f.controller.close()
  100. })
  101. it('waits for failed ready child cleanup before preparing a retry', async () => {
  102. const f = fixture()
  103. f.ready.resolve()
  104. await f.controller.start(async () => {})
  105. f.fail(new Error('transport failed'))
  106. expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' })
  107. expect(f.controller.host).toBeUndefined()
  108. await f.stopping.promise
  109. const prepare = vi.fn(async () => {})
  110. const retry = f.controller.start(prepare)
  111. await Promise.resolve()
  112. expect(prepare).not.toHaveBeenCalled()
  113. f.exited.resolve()
  114. await retry
  115. expect(prepare).toHaveBeenCalledTimes(1)
  116. expect(f.create).toHaveBeenCalledTimes(2)
  117. await f.controller.close()
  118. })
  119. it('does not publish ready when the child fails as readiness settles', async () => {
  120. const f = fixture()
  121. const start = f.controller.start(async () => {})
  122. const rejected = expect(start).rejects.toThrow('immediate failure')
  123. await f.started.promise
  124. f.ready.resolve()
  125. f.fail(new Error('immediate failure'))
  126. await f.stopping.promise
  127. f.exited.resolve()
  128. await rejected
  129. expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }])
  130. await f.controller.close()
  131. })
  132. it('ignores failure callbacks caused by intentional stop and permits subsequent start', async () => {
  133. const f = fixture()
  134. f.ready.resolve()
  135. await f.controller.start(async () => {})
  136. const stop = f.controller.stop()
  137. await f.stopping.promise
  138. f.fail(new Error('stopped'))
  139. await expect(f.controller.start(async () => {})).rejects.toThrow('stopping')
  140. expect(f.controller.state).toEqual({ phase: 'starting' })
  141. f.exited.resolve()
  142. await stop
  143. await f.controller.start(async () => {})
  144. expect(f.create).toHaveBeenCalledTimes(2)
  145. await f.controller.close()
  146. })
  147. it('propagates cleanup failure and prevents retry from overlapping the remaining child', async () => {
  148. const f = fixture()
  149. f.ready.resolve()
  150. await f.controller.start(async () => {})
  151. f.fail(new Error('fatal'))
  152. await f.stopping.promise
  153. f.exited.reject(new Error('child did not exit'))
  154. await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit')
  155. await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit')
  156. expect(f.create).toHaveBeenCalledTimes(1)
  157. await expect(f.controller.close()).rejects.toThrow('child did not exit')
  158. })
  159. })