browser-plugin.spec.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  1. /**
  2. * ui-plan browser half on a real SlotsService: the plugin occupies the
  3. * conversation-declared `conversation.input.plan` single seat with the active
  4. * plan status chip; the injected face executes /plan off and folds admission
  5. * outcomes into null (admitted) or a user-visible failure line; teardown
  6. * empties the seat (HMR safety).
  7. */
  8. import { Context } from 'cordis'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  11. import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  12. import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
  13. import { PlanChip } from '../src/client/PlanModeControl.tsx'
  14. import type { PlanChipInjected } from '../src/client/index.ts'
  15. import { apply, inject } from '../src/client/index.ts'
  16. import { apply as nodeApply } from '../src/index.ts'
  17. const SID = 's-plan' as SessionId
  18. async function bench() {
  19. const ctx = new Context()
  20. await ctx.plugin(SlotsService).await()
  21. const slots = ctx.get('slots') as SlotsService
  22. slots.register({
  23. name: 'root',
  24. children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } },
  25. } as never, () => null)
  26. const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) =>
  27. Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } }))
  28. ctx.provide('connection', { api: { commands: { execute } } })
  29. ctx.provide('conversation', {})
  30. ctx.provide('locale', new LocaleService(ctx))
  31. return { ctx, slots, execute }
  32. }
  33. describe('ui-plan browser apply', () => {
  34. it('declares every service it binds', () => {
  35. expect(inject).toEqual(['slots', 'connection', 'conversation', 'locale'])
  36. })
  37. it('node-half apply is an intentional no-op', () => {
  38. expect(() => { nodeApply() }).not.toThrow()
  39. })
  40. it('fails loud when conversation did not declare the plan seat', async () => {
  41. const ctx = new Context()
  42. await ctx.plugin(SlotsService).await()
  43. ctx.provide('connection', {})
  44. ctx.provide('conversation', {})
  45. ctx.provide('locale', new LocaleService(ctx))
  46. await expect(ctx.plugin({ inject: [...inject], apply }))
  47. .rejects.toThrow(/slot "conversation.input.plan" is not declared/)
  48. })
  49. it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
  50. const b = await bench()
  51. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  52. await fiber.await()
  53. const entry = b.slots.entries('conversation.input.plan')[0]!
  54. expect(entry.component).toBe(PlanChip)
  55. const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
  56. await expect(injected.exitPlanMode()).resolves.toBeNull()
  57. expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
  58. // Business failure folds to the composer-visible line.
  59. b.execute.mockResolvedValueOnce({
  60. result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
  61. } as never)
  62. await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)')
  63. // Unmatched admission (plan-mode not composed host-side) is also a failure line.
  64. b.execute.mockResolvedValueOnce({
  65. result: { ok: true as const, value: { matched: false as const } },
  66. } as never)
  67. await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off')
  68. await fiber.dispose()
  69. expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
  70. })
  71. })