user-interaction.spec.ts 3.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import UserInteractionService, {
  4. UserInteractionError,
  5. type AskUserQuestionRequest,
  6. type UserInteractionProvider,
  7. } from '@deepseek-ai/dsh-user-interaction'
  8. function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } {
  9. const seen: AskUserQuestionRequest[] = []
  10. return {
  11. seen,
  12. async ask(request) {
  13. seen.push(request)
  14. return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] }
  15. },
  16. }
  17. }
  18. describe('UserInteractionService', () => {
  19. it('delegates ask requests to the registered provider', async () => {
  20. const ctx = new Context()
  21. await ctx.plugin(UserInteractionService)
  22. const p = provider('yes')
  23. ctx.userInteraction.registerProvider(p)
  24. const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
  25. expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
  26. expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
  27. })
  28. it('rejects ask requests when no provider is registered', async () => {
  29. const ctx = new Context()
  30. await ctx.plugin(UserInteractionService)
  31. await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
  32. .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
  33. })
  34. it('registers providers with HMR-safe disposal', async () => {
  35. const ctx = new Context()
  36. await ctx.plugin(UserInteractionService)
  37. const p = provider()
  38. const dispose = ctx.userInteraction.registerProvider(p)
  39. dispose()
  40. dispose()
  41. await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
  42. .rejects.toMatchObject({ code: 'NO_PROVIDER' })
  43. })
  44. it('rejects duplicate providers instead of replacing the active UI', async () => {
  45. const ctx = new Context()
  46. await ctx.plugin(UserInteractionService)
  47. ctx.userInteraction.registerProvider(provider('first'))
  48. expect(() => ctx.userInteraction.registerProvider(provider('second')))
  49. .toThrow(UserInteractionError)
  50. })
  51. it('fails before reaching the provider when the signal is already aborted', async () => {
  52. const ctx = new Context()
  53. await ctx.plugin(UserInteractionService)
  54. const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
  55. ctx.userInteraction.registerProvider(p)
  56. const controller = new AbortController()
  57. controller.abort()
  58. await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
  59. .rejects.toMatchObject({ code: 'ASK_ABORTED' })
  60. expect(p.ask).not.toHaveBeenCalled()
  61. })
  62. it('rejects empty question batches before reaching the provider', async () => {
  63. const ctx = new Context()
  64. await ctx.plugin(UserInteractionService)
  65. const p = { ask: vi.fn(async () => ({ answers: [] })) }
  66. ctx.userInteraction.registerProvider(p)
  67. await expect(ctx.userInteraction.ask({ questions: [] }))
  68. .rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
  69. expect(p.ask).not.toHaveBeenCalled()
  70. })
  71. })