browser-plugin.client.spec.ts 4.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. /**
  2. * ui-commands browser half on a real cordis Context with fake slash/slots
  3. * faces and real session scopes: the plugin body mounts CommandUiRuntime as
  4. * `command`, the popupSelect shell registers into conversation.input.overlay
  5. * through slot declaration injection with a per-session inject (sessionId →
  6. * scope → popupFor; unknown id fails loud), both fold up on fiber disposal
  7. * (HMR safety), and the service satisfies the frozen CommandUiContract.
  8. */
  9. import { Context } from '@deepseek-ai/cordis'
  10. import { describe, expect, it, onTestFinished } from 'vitest'
  11. import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
  12. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  13. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  14. import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  15. import type { CommandUiContract } from '../src/client/contract.ts'
  16. import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
  17. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  18. import { apply, CommandUiRuntime, inject } from '../src/client/index.ts'
  19. const sid = (k: string): SessionId => k as SessionId
  20. async function bench() {
  21. const ctx = new Context()
  22. const sources = new Map<string, InputTriggerSource>()
  23. ctx.provide('inputTriggers', {
  24. registerSource(src: InputTriggerSource) {
  25. sources.set(`${src.trigger} ${src.name}`, src)
  26. return () => { sources.delete(`${src.trigger} ${src.name}`) }
  27. },
  28. })
  29. const scopes = new Map<SessionId, Context>()
  30. ctx.provide('sessions', {
  31. scope: (id: SessionId) => scopes.get(id),
  32. scopeOf: (c: Context) => scopeOf(c),
  33. subagentAddress: (id: SessionId) => id === sid('child')
  34. ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
  35. : undefined,
  36. })
  37. const commandsRemote = { list: () => Promise.resolve({ ok: true as const, value: [] }) }
  38. // The service subscribes its cache-invalidation events on construction, so
  39. // the Remote face needs `$on` even where this spec dispatches none.
  40. ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} })
  41. ctx.provide('remote.commands', commandsRemote)
  42. await ctx.plugin(SlotRegistry).await()
  43. ctx.slots.register({
  44. name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
  45. } as never, (() => null) as never)
  46. ctx.provide('locale', new LocaleRuntime(ctx))
  47. const fiber = ctx.plugin({ inject: [...inject], apply })
  48. await fiber.await()
  49. const mint = (key: string) => {
  50. const handle = createScope(ctx, sid(key))
  51. scopes.set(sid(key), handle.ctx)
  52. return handle
  53. }
  54. return { ctx, fiber, sources, slots: ctx.slots, mint }
  55. }
  56. describe('apply', () => {
  57. it('declares the services it binds', () => {
  58. expect(inject).toEqual(['inputTriggers', 'sessions', 'remote', 'remote.commands', 'locale'])
  59. })
  60. it('mounts ctx.commandUi, registers the source and the overlay entry, and folds up on disposal', async () => {
  61. const { ctx, fiber, sources, slots } = await bench()
  62. const command = ctx.get('commandUi')
  63. expect(command).toBeInstanceOf(CommandUiRuntime)
  64. // Frozen-contract conformance (compile-time check rides the assignment).
  65. const contract: CommandUiContract = command as CommandUiRuntime
  66. expect(typeof contract.register).toBe('function')
  67. expect(typeof contract.popupFor).toBe('function')
  68. expect([...sources.keys()]).toEqual(['/ command'])
  69. expect(slots.entries('conversation.input.overlay').map(entry => entry.options.id)).toEqual(['command-popup'])
  70. await fiber.dispose()
  71. expect(sources.size).toBe(0)
  72. expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
  73. })
  74. it('provides no File action without its composer owner', async () => {
  75. const { fiber, sources } = await bench()
  76. onTestFinished(() => fiber.dispose())
  77. const source = sources.get('/ command')!
  78. const req = { query: '', position: 'leading' as const, drilled: false, signal: new AbortController().signal }
  79. expect(await source.candidates({ sessionId: sid('s1') }, req)).toEqual([])
  80. expect(await source.candidates({ sessionId: sid('child') }, req)).toEqual([])
  81. })
  82. it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {
  83. const { ctx, slots, mint } = await bench()
  84. const command = ctx.get('commandUi') as CommandUiRuntime
  85. const scope = mint('s1')
  86. const entry = slots.entries('conversation.input.overlay')[0]!
  87. const injectEntry = entry.inject as unknown as (sessionId: SessionId) => PopupSelectInjected
  88. expect(injectEntry(sid('s1')).popup).toBe(command.popupFor(scope.ctx))
  89. expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
  90. })
  91. })