apply.client.spec.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  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. ConfigurablePluginsTabFace, 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. /**
  17. * @param served - namespaces the Host describes; omitted answers a failed read,
  18. * which is what most of these specs want (no card has anything to render).
  19. */
  20. async function bench(served?: string[]) {
  21. const ctx = new Context()
  22. await ctx.plugin(SlotRegistry).await()
  23. const locale = new LocaleRuntime(ctx)
  24. ctx.provide('locale', locale)
  25. const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
  26. const describeSettings = vi.fn(() => Promise.resolve(served === undefined
  27. ? { rpcId: 's', result: { ok: false, error: {} } }
  28. : {
  29. rpcId: 's',
  30. result: {
  31. ok: true,
  32. value: {
  33. writable: true,
  34. hasDocument: true,
  35. namespaces: served.map(ns => ({
  36. ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
  37. })),
  38. },
  39. },
  40. }))
  41. // The section binds its scopes through the Settings surface's service, and
  42. // forwarded Host events reach it through the same `$dispatch` handoff the
  43. // connection sink makes.
  44. new TestRemote(ctx)
  45. ctx.provide('connection', {
  46. isLoopback: true,
  47. api: {
  48. settings: { describe: describeSettings },
  49. credentials: { describe: describeCredentials },
  50. },
  51. } as never)
  52. await ctx.plugin(SettingsScopeBinder).await()
  53. return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings }
  54. }
  55. function declareRoot(slots: SlotRegistry): () => void {
  56. return slots.register({
  57. name: 'root',
  58. children: { 'settings.section': { kind: 'list', scope: 'root' } },
  59. } as never, () => null)
  60. }
  61. describe('ui-settings-plugins apply', () => {
  62. it('declares the services it uses', () => {
  63. expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
  64. })
  65. it('registers one Plugins section and declares the tab and card slots', async () => {
  66. const { ctx, slots } = await bench()
  67. declareRoot(slots)
  68. await ctx.plugin({ inject: [...inject], apply }).await()
  69. const section = slots.entries('settings.section')[0]!
  70. expect(section.options).toMatchObject({ id: 'plugins', order: 15 })
  71. // The nav label is a locale-following thunk; owners resolve it at read time.
  72. expect(resolveSlotLabel(section.options.label)).toBe('插件')
  73. expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' })
  74. const tab = slots.entries('settings.plugins.tab')[0]!
  75. expect(tab.options).toMatchObject({ id: 'configurable', order: 0 })
  76. expect(resolveSlotLabel(tab.options.label)).toBe('插件配置')
  77. expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
  78. })
  79. it('injects a live tab projection, the card directory, and one business face per card', async () => {
  80. const { ctx, slots } = await bench()
  81. declareRoot(slots)
  82. await ctx.plugin({ inject: [...inject], apply }).await()
  83. const section = slots.entries('settings.section')[0]!
  84. const sectionFace = (section.inject as unknown as () => PluginsSettingsSectionInjected)()
  85. const initialTabs = sectionFace.hooks.tabs.getSnapshot()
  86. expect(initialTabs).toEqual([
  87. { id: 'configurable', order: 0, label: '插件配置' },
  88. ])
  89. expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs)
  90. const listener = vi.fn()
  91. const unsubscribe = sectionFace.hooks.tabs.subscribe(listener)
  92. slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null)
  93. expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([
  94. { id: 'configurable', order: 0, label: '插件配置' },
  95. { id: 'plain', order: 0, label: '' },
  96. ])
  97. unsubscribe()
  98. const tab = slots.entries('settings.plugins.tab')[0]!
  99. const tabFace = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
  100. expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins'])
  101. for (const entry of slots.entries('settings.plugin.item')) {
  102. const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
  103. // Each card injects exactly one snapshot store plus its own actions.
  104. expect(Object.keys(face.hooks)).toHaveLength(1)
  105. }
  106. })
  107. it('keys each card it ships on the settings namespace that card edits', async () => {
  108. const { ctx, slots } = await bench()
  109. declareRoot(slots)
  110. await ctx.plugin({ inject: [...inject], apply }).await()
  111. expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
  112. .toEqual(['shell', 'agent-loop', 'web-search-deepseek'])
  113. })
  114. it('dispatches the served namespaces its cards claim, and no others', async () => {
  115. // ui-theme is served but belongs to another surface, and a deployment
  116. // composing no PowerShell/POSIX executor serves no `bash` at all.
  117. const { ctx, slots } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek'])
  118. declareRoot(slots)
  119. await ctx.plugin({ inject: [...inject], apply }).await()
  120. const tab = slots.entries('settings.plugins.tab')[0]!
  121. const face = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
  122. await vi.waitFor(() => {
  123. expect(face.hooks.configurablePlugins.getSnapshot().namespaces)
  124. .toEqual(['agent-loop', 'web-search-deepseek'])
  125. })
  126. })
  127. it('re-reads the served namespaces when the Host commits a settings document', async () => {
  128. // Which namespaces the Host serves is a registration fact the wire never
  129. // announces on its own, so the tab rides the invalidation that can
  130. // accompany a changed composition.
  131. const { ctx, slots, describeSettings } = await bench(['bash'])
  132. declareRoot(slots)
  133. await ctx.plugin({ inject: [...inject], apply }).await()
  134. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
  135. describeSettings.mockClear()
  136. ctx.remote.$dispatch('settings/document-updated', ['bash', 1])
  137. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
  138. })
  139. it('re-reads the served namespaces after a reconnect', async () => {
  140. const { ctx, slots, describeSettings } = await bench(['bash'])
  141. declareRoot(slots)
  142. await ctx.plugin({ inject: [...inject], apply }).await()
  143. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
  144. describeSettings.mockClear()
  145. ctx.emit('connection/reset')
  146. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
  147. })
  148. it('re-reads the credential when the Host reports the watched reference changed', async () => {
  149. const { ctx, slots, describeCredentials } = await bench()
  150. declareRoot(slots)
  151. await ctx.plugin({ inject: [...inject], apply }).await()
  152. await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
  153. describeCredentials.mockClear()
  154. // A key written on another surface changes no settings section, so this
  155. // event is the only thing that reaches the card.
  156. ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
  157. await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
  158. })
  159. it('ignores a credential change for a reference no card watches', async () => {
  160. const { ctx, slots, describeCredentials } = await bench()
  161. declareRoot(slots)
  162. await ctx.plugin({ inject: [...inject], apply }).await()
  163. await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
  164. describeCredentials.mockClear()
  165. ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY'])
  166. await Promise.resolve()
  167. expect(describeCredentials).not.toHaveBeenCalled()
  168. })
  169. it('registers into a declaration that arrives after apply', async () => {
  170. const { ctx, slots } = await bench()
  171. await ctx.plugin({ inject: [...inject], apply }).await()
  172. declareRoot(slots)
  173. await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
  174. })
  175. it('collapses every contribution on teardown', async () => {
  176. const { ctx, slots } = await bench()
  177. declareRoot(slots)
  178. const fiber = ctx.plugin({ inject: [...inject], apply })
  179. await fiber.await()
  180. expect(slots.entries('settings.plugin.item')).toHaveLength(3)
  181. await fiber.dispose()
  182. expect(slots.entries('settings.section')).toHaveLength(0)
  183. expect(slots.spec('settings.plugins.tab')).toBeUndefined()
  184. expect(slots.spec('settings.plugin.item')).toBeUndefined()
  185. })
  186. })