apply.client.spec.ts 6.7 KB

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