browser-plugin.client.spec.ts 3.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. /**
  2. * ui-plan browser half on a real SlotRegistry: 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 '@deepseek-ai/cordis'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  11. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  12. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  13. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  14. import { PlanChip } from '../src/client/PlanModeControl.tsx'
  15. import type { PlanChipInjected } from '../src/client/index.ts'
  16. import { apply, inject } from '../src/client/index.ts'
  17. import { apply as nodeApply } from '../src/index.ts'
  18. const SID = 's-plan' as SessionId
  19. async function bench() {
  20. const ctx = new Context()
  21. await ctx.plugin(SlotRegistry).await()
  22. const slots = ctx.get('slots') as SlotRegistry
  23. slots.register({
  24. name: 'root',
  25. children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } },
  26. } as never, () => null)
  27. const execute = vi.fn((_sessionId: SessionId, _line: string) =>
  28. Promise.resolve({ ok: true, value: { commandId: 'c1', result: { kind: 'success' as const } } }))
  29. const commandsRemote = { execute }
  30. ctx.provide('remote', { commands: commandsRemote })
  31. ctx.provide('remote.commands', commandsRemote)
  32. ctx.provide('locale', new LocaleRuntime(ctx))
  33. return { ctx, slots, execute }
  34. }
  35. describe('ui-plan browser apply', () => {
  36. it('declares every service it binds', () => {
  37. expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'locale'])
  38. })
  39. it('node-half apply is an intentional no-op', () => {
  40. expect(() => { nodeApply() }).not.toThrow()
  41. })
  42. it('waits until conversation declares the plan seat', async () => {
  43. const ctx = new Context()
  44. await ctx.plugin(SlotRegistry).await()
  45. ctx.provide('remote', { commands: {} })
  46. ctx.provide('remote.commands', {})
  47. ctx.provide('locale', new LocaleRuntime(ctx))
  48. const fiber = ctx.plugin({ inject: [...inject], apply })
  49. await fiber.await()
  50. expect(ctx.slots.entries('conversation.input.plan')).toHaveLength(0)
  51. ctx.slots.register({
  52. name: 'root', children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } },
  53. } as never, () => null)
  54. await Promise.resolve()
  55. expect(ctx.slots.entries('conversation.input.plan')).toHaveLength(1)
  56. })
  57. it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
  58. const b = await bench()
  59. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  60. await fiber.await()
  61. const entry = b.slots.entries('conversation.input.plan')[0]!
  62. expect(entry.component).toBe(PlanChip)
  63. const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
  64. await expect(injected.exitPlanMode()).resolves.toBeNull()
  65. expect(b.execute).toHaveBeenLastCalledWith(SID, '/plan off', [])
  66. // Business failure folds to the composer-visible line: the generated method
  67. // reports the RPC failure in its error branch.
  68. b.execute.mockResolvedValueOnce({
  69. ok: false,
  70. error: new RemoteError('session/not-found', 'gone', { sessionId: SID }),
  71. } as never)
  72. await expect(injected.exitPlanMode()).resolves.toBe('gone (session/not-found)')
  73. // Unmatched admission (plan-mode not composed host-side) is also a failure line.
  74. b.execute.mockResolvedValueOnce({ ok: true, value: undefined } as never)
  75. await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off')
  76. await fiber.dispose()
  77. expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
  78. })
  79. })