service.spec.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { BashExecutor } from '@deepseek-ai/dsh-bash'
  4. import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
  5. /** Minimal concrete executor: records calls, lets tests drive completions. */
  6. class StubExecutor extends BashExecutor {
  7. tasks = new Map<string, BashTask>()
  8. resolve(request: BashExecRequest): BashExecSpec {
  9. return {
  10. command: request.command,
  11. workdir: request.workdir ?? '/stub',
  12. timeoutMs: request.timeoutMs ?? 1000,
  13. ...request.signal ? { signal: request.signal } : {},
  14. }
  15. }
  16. async run(_spec: BashExecSpec): Promise<BashRunResult> {
  17. return {
  18. exitCode: 0,
  19. signal: null,
  20. timedOut: false,
  21. aborted: false,
  22. timeoutMs: 1000,
  23. stdout: { text: 'ok', truncated: false },
  24. stderr: { text: '', truncated: false },
  25. }
  26. }
  27. start(spec: BashExecSpec): BashTask {
  28. const task: BashTask = {
  29. id: `stub-${this.tasks.size + 1}`,
  30. command: spec.command,
  31. status: 'running',
  32. exitCode: null,
  33. signal: null,
  34. done: Promise.resolve(),
  35. }
  36. this.tasks.set(task.id, task)
  37. return task
  38. }
  39. get(id: string): BashTask | undefined {
  40. return this.tasks.get(id)
  41. }
  42. list(): BashTask[] {
  43. return [...this.tasks.values()]
  44. }
  45. readOutput(id: string): BashTaskRead {
  46. const task = this.tasks.get(id)
  47. if (!task) throw new Error(`unknown bash task "${id}"`)
  48. return { task, delta: '', lossy: false }
  49. }
  50. kill(id: string): boolean {
  51. const task = this.tasks.get(id)
  52. if (!task) throw new Error(`unknown bash task "${id}"`)
  53. if (task.status !== 'running') return false
  54. task.status = 'killed'
  55. return true
  56. }
  57. /** Expose the protected notifier for tests. */
  58. fire(task: BashTask): void {
  59. this.notifyTaskDone(task)
  60. }
  61. }
  62. async function setup() {
  63. const ctx = new Context()
  64. await ctx.plugin(StubExecutor)
  65. // ctx.bash resolves to the registered implementation.
  66. const bash = ctx.bash as StubExecutor
  67. return { ctx, bash }
  68. }
  69. describe('BashExecutor service seam', () => {
  70. it('registers as ctx.bash and serves the abstract API', async () => {
  71. const { bash } = await setup()
  72. const task = bash.start(bash.resolve({ command: 'sleep 1' }))
  73. expect(bash.get(task.id)).toBe(task)
  74. expect(bash.list()).toEqual([task])
  75. expect(bash.kill(task.id)).toBe(true)
  76. expect(bash.kill(task.id)).toBe(false)
  77. const result = await bash.run(bash.resolve({ command: 'true' }))
  78. expect(result.exitCode).toBe(0)
  79. })
  80. it('onTaskDone delivers completions to registered listeners', async () => {
  81. const { bash } = await setup()
  82. const seen: string[] = []
  83. bash.onTaskDone(task => void seen.push(task.id))
  84. const task = bash.start(bash.resolve({ command: 'x' }))
  85. bash.fire(task)
  86. expect(seen).toEqual([task.id])
  87. })
  88. it('onTaskDone disposer unsubscribes the listener', async () => {
  89. const { bash } = await setup()
  90. const listener = vi.fn()
  91. const dispose = bash.onTaskDone(listener)
  92. dispose()
  93. bash.fire(bash.start(bash.resolve({ command: 'x' })))
  94. expect(listener).not.toHaveBeenCalled()
  95. })
  96. it('listeners registered from a fiber are removed on dispose (HMR safety)', async () => {
  97. const { ctx, bash } = await setup()
  98. const listener = vi.fn()
  99. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  100. inner.bash.onTaskDone(listener)
  101. }, { inject: ['bash'] }))
  102. bash.fire(bash.start(bash.resolve({ command: 'one' })))
  103. expect(listener).toHaveBeenCalledTimes(1)
  104. await fiber.dispose()
  105. bash.fire(bash.start(bash.resolve({ command: 'two' })))
  106. expect(listener).toHaveBeenCalledTimes(1)
  107. })
  108. it('silences listeners once the service fiber is disposed', async () => {
  109. const ctx = new Context()
  110. const fiber = await ctx.plugin(Object.assign(async (inner: Context) => {
  111. await inner.plugin(StubExecutor)
  112. }, {}))
  113. const bash = ctx.bash as StubExecutor
  114. const listener = vi.fn()
  115. bash.onTaskDone(listener)
  116. const task = bash.start(bash.resolve({ command: 'x' }))
  117. await fiber.dispose()
  118. bash.fire(task)
  119. expect(listener).not.toHaveBeenCalled()
  120. })
  121. })