browser-plugin.client.spec.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. /**
  2. * ui-command browser half on a real cordis Context with fake slash/slots
  3. * faces and real session scopes: the plugin body mounts CommandService 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 CommandServiceContract.
  8. */
  9. import { Context } from '@deepseek-ai/cordis'
  10. import { describe, expect, it } from 'vitest'
  11. import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  12. import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  13. import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
  14. import type { CommandServiceContract } from '../src/client/contract.ts'
  15. import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
  16. import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
  17. import { apply, CommandService, inject } from '../src/client/index.ts'
  18. const sid = (k: string): SessionId => k as SessionId
  19. async function bench() {
  20. const ctx = new Context()
  21. const sources = new Map<string, SlashSource>()
  22. ctx.provide('slash', {
  23. registerSource(src: SlashSource) {
  24. sources.set(`${src.trigger} ${src.name}`, src)
  25. return () => { sources.delete(`${src.trigger} ${src.name}`) }
  26. },
  27. })
  28. const scopes = new Map<SessionId, Context>()
  29. ctx.provide('sessions', {
  30. scope: (id: SessionId) => scopes.get(id),
  31. scopeOf: (c: Context) => scopeOf(c),
  32. })
  33. const commandsRemote = { list: () => Promise.resolve([]) }
  34. // The service subscribes its cache-invalidation events on construction, so
  35. // the Remote face needs `$on` even where this spec dispatches none.
  36. ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} })
  37. ctx.provide('remote.commands', commandsRemote)
  38. await ctx.plugin(SlotsService).await()
  39. ctx.slots.register({
  40. name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
  41. } as never, (() => null) as never)
  42. ctx.provide('locale', new LocaleService(ctx))
  43. const fiber = ctx.plugin({ inject: [...inject], apply })
  44. await fiber.await()
  45. const mint = (key: string) => {
  46. const handle = createScope(ctx, sid(key))
  47. scopes.set(sid(key), handle.ctx)
  48. return handle
  49. }
  50. return { ctx, fiber, sources, slots: ctx.slots, mint }
  51. }
  52. describe('apply', () => {
  53. it('declares the services it binds', () => {
  54. expect(inject).toEqual(['slash', 'sessions', 'remote', 'remote.commands', 'locale'])
  55. })
  56. it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
  57. const { ctx, fiber, sources, slots } = await bench()
  58. const command = ctx.get('command')
  59. expect(command).toBeInstanceOf(CommandService)
  60. // Frozen-contract conformance (compile-time check rides the assignment).
  61. const contract: CommandServiceContract = command as CommandService
  62. expect(typeof contract.register).toBe('function')
  63. expect(typeof contract.popupFor).toBe('function')
  64. expect([...sources.keys()]).toEqual(['/ command'])
  65. expect(slots.entries('conversation.input.overlay').map(entry => entry.options.id)).toEqual(['command-popup'])
  66. await fiber.dispose()
  67. expect(sources.size).toBe(0)
  68. expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
  69. })
  70. it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {
  71. const { ctx, slots, mint } = await bench()
  72. const command = ctx.get('command') as CommandService
  73. const scope = mint('s1')
  74. const entry = slots.entries('conversation.input.overlay')[0]!
  75. const injectEntry = entry.inject as unknown as (sessionId: SessionId) => PopupSelectInjected
  76. expect(injectEntry(sid('s1')).popup).toBe(command.popupFor(scope.ctx))
  77. expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
  78. })
  79. })