apply.spec.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. /** What the browser half registers, and that it all leaves with the fiber. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
  5. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  6. import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
  7. import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
  8. import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
  9. import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
  10. // The service reads its initial locale from the browser; these specs assert
  11. // the shipped Chinese copy, so they state the browser they assume.
  12. usePinnedBrowserLanguages('zh-CN')
  13. async function bench() {
  14. const ctx = new Context()
  15. await ctx.plugin(SlotsService).await()
  16. const locale = new LocaleService(ctx)
  17. ctx.provide('locale', locale)
  18. const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
  19. // The section binds its scopes through the Settings surface's service, and
  20. // forwarded Host events reach it through the same `$dispatch` handoff the
  21. // connection sink makes.
  22. new TestRemote(ctx)
  23. ctx.provide('connection', {
  24. isLoopback: true,
  25. api: {
  26. settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
  27. credentials: { describe: describeCredentials },
  28. },
  29. } as never)
  30. await ctx.plugin(SettingsScopeService).await()
  31. return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials }
  32. }
  33. function declareRoot(slots: SlotsService): () => void {
  34. return slots.register({
  35. name: 'root',
  36. children: { 'settings.section': { kind: 'list', scope: 'root' } },
  37. } as never, () => null)
  38. }
  39. describe('ui-plugin-config apply', () => {
  40. it('declares the services it uses', () => {
  41. expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
  42. })
  43. it('registers the section and declares the per-plugin card slot', async () => {
  44. const { ctx, slots } = await bench()
  45. declareRoot(slots)
  46. await ctx.plugin({ inject: [...inject], apply }).await()
  47. const section = slots.entries('settings.section')[0]!
  48. expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
  49. // The nav label is a locale-following thunk; owners resolve it at read time.
  50. expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
  51. expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
  52. })
  53. it('registers one card per host-plane section it ships, in a stable order', async () => {
  54. const { ctx, slots } = await bench()
  55. declareRoot(slots)
  56. await ctx.plugin({ inject: [...inject], apply }).await()
  57. expect(slots.entries('settings.plugin.item').map(entry => entry.options.id))
  58. .toEqual(['bash', 'agent-loop', 'web-search'])
  59. })
  60. it('injects a live card count and one business face per card', async () => {
  61. const { ctx, slots } = await bench()
  62. declareRoot(slots)
  63. await ctx.plugin({ inject: [...inject], apply }).await()
  64. const section = slots.entries('settings.section')[0]!
  65. expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
  66. for (const entry of slots.entries('settings.plugin.item')) {
  67. const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
  68. // Each card injects exactly one snapshot store plus its own actions.
  69. expect(Object.keys(face.hooks)).toHaveLength(1)
  70. }
  71. })
  72. it('re-reads the credential when the Host reports the watched reference changed', async () => {
  73. const { ctx, slots, describeCredentials } = await bench()
  74. declareRoot(slots)
  75. await ctx.plugin({ inject: [...inject], apply }).await()
  76. await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
  77. describeCredentials.mockClear()
  78. // A key written on another surface changes no settings section, so this
  79. // event is the only thing that reaches the card.
  80. ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
  81. await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
  82. })
  83. it('ignores a credential change for a reference no card watches', async () => {
  84. const { ctx, slots, describeCredentials } = await bench()
  85. declareRoot(slots)
  86. await ctx.plugin({ inject: [...inject], apply }).await()
  87. await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
  88. describeCredentials.mockClear()
  89. ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY'])
  90. await Promise.resolve()
  91. expect(describeCredentials).not.toHaveBeenCalled()
  92. })
  93. it('registers into a declaration that arrives after apply', async () => {
  94. const { ctx, slots } = await bench()
  95. await ctx.plugin({ inject: [...inject], apply }).await()
  96. declareRoot(slots)
  97. await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
  98. })
  99. it('collapses every contribution on teardown', async () => {
  100. const { ctx, slots } = await bench()
  101. declareRoot(slots)
  102. const fiber = ctx.plugin({ inject: [...inject], apply })
  103. await fiber.await()
  104. expect(slots.entries('settings.plugin.item')).toHaveLength(3)
  105. await fiber.dispose()
  106. expect(slots.entries('settings.section')).toHaveLength(0)
  107. expect(slots.spec('settings.plugin.item')).toBeUndefined()
  108. })
  109. })