apply.client.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  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-ui-renderer/client'
  6. import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
  7. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  8. import { apply as settingsApply, inject as settingsInject } 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. BashCardFace, ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, PresetSettingsFace,
  12. } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
  13. import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts'
  14. import { apply as hostApply } from '../src/index.ts'
  15. // These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
  16. // so browser-language detection never runs and a fresh LocaleRuntime opens on
  17. // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead.
  18. /**
  19. * @param served - namespaces the Host describes; omitted answers a failed read,
  20. * which is what most of these specs want (no card has anything to render).
  21. */
  22. async function bench(served?: string[]) {
  23. const ctx = new Context()
  24. await ctx.plugin(SlotRegistry).await()
  25. const locale = new LocaleRuntime(ctx)
  26. locale.setLocale('zh')
  27. ctx.provide('locale', locale)
  28. const describeCredentials = vi.fn(() => Promise.resolve({
  29. ok: false, error: new RemoteError('gateway/internal', 'no provider', {}),
  30. }))
  31. const models = vi.fn(() => Promise.resolve({
  32. ok: true as const, value: { groups: [], failures: [] },
  33. }))
  34. const describeSettings = vi.fn((scope?: string) => Promise.resolve(served === undefined
  35. ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) }
  36. : {
  37. ok: true,
  38. value: {
  39. writable: true,
  40. hasDocument: true,
  41. ...scope === undefined ? { scopes: [] } : { scope, scopes: [scope] },
  42. namespaces: served.map(ns => ({
  43. ns, registered: scope === undefined, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
  44. ...scope === undefined ? {} : { scope, inherited: {} },
  45. })),
  46. },
  47. }))
  48. const remote = new TestRemote(ctx, {
  49. credentials: { describe: describeCredentials, set: vi.fn() },
  50. session: { modelCatalog: models },
  51. settings: { describe: describeSettings },
  52. })
  53. await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
  54. return {
  55. ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote,
  56. }
  57. }
  58. function declareRoot(slots: SlotRegistry): () => void {
  59. return slots.register({
  60. name: 'root',
  61. children: {
  62. 'settings.section': { kind: 'list', scope: 'root' },
  63. 'settings.agentPreset.detail': { kind: 'list', scope: 'root' },
  64. },
  65. } as never, () => null)
  66. }
  67. describe('ui-settings-plugins apply', () => {
  68. it('keeps the host Loader entry inert', () => {
  69. expect(hostApply).not.toThrow()
  70. })
  71. it('declares the services it uses', () => {
  72. expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope'])
  73. })
  74. it('registers one Plugins section and one preset detail section, and declares the tab and card slots', async () => {
  75. const { ctx, slots } = await bench()
  76. declareRoot(slots)
  77. await ctx.plugin({ inject: [...inject], apply }).await()
  78. const section = slots.entries('settings.section')[0]!
  79. expect(section.options).toMatchObject({ id: 'plugins', order: 15 })
  80. // The nav label is a locale-following thunk; owners resolve it at read time.
  81. expect(resolveSlotLabel(section.options.label)).toBe('插件')
  82. expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' })
  83. const tab = slots.entries('settings.plugins.tab')[0]!
  84. expect(tab.options).toMatchObject({ id: 'configurable', order: 0 })
  85. expect(resolveSlotLabel(tab.options.label)).toBe('插件配置')
  86. expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
  87. // The preset detail section follows the plugin manager's capabilities section.
  88. const detail = slots.entries('settings.agentPreset.detail')[0]!
  89. expect(detail.options).toMatchObject({ id: 'settings', order: 10 })
  90. expect(slots.spec('settings.agentPreset.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
  91. })
  92. it('injects a live tab projection, the card directory, and one business face per card', async () => {
  93. const { ctx, slots } = await bench()
  94. declareRoot(slots)
  95. await ctx.plugin({ inject: [...inject], apply }).await()
  96. const section = slots.entries('settings.section')[0]!
  97. const sectionFace = (section.inject as unknown as () => PluginsSettingsSectionInjected)()
  98. const initialTabs = sectionFace.hooks.tabs.getSnapshot()
  99. expect(initialTabs).toEqual([
  100. { id: 'configurable', order: 0, label: '插件配置' },
  101. ])
  102. expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs)
  103. const listener = vi.fn()
  104. const unsubscribe = sectionFace.hooks.tabs.subscribe(listener)
  105. slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null)
  106. expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([
  107. { id: 'configurable', order: 0, label: '插件配置' },
  108. { id: 'plain', order: 0, label: '' },
  109. ])
  110. unsubscribe()
  111. const tab = slots.entries('settings.plugins.tab')[0]!
  112. const tabFace = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
  113. expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins'])
  114. for (const entry of slots.entries('settings.plugin.item')) {
  115. const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
  116. // Each card injects exactly one snapshot store plus its own actions.
  117. expect(Object.keys(face.hooks)).toHaveLength(1)
  118. }
  119. })
  120. it('keys each card it ships on the settings namespace that card edits', async () => {
  121. const { ctx, slots } = await bench()
  122. declareRoot(slots)
  123. await ctx.plugin({ inject: [...inject], apply }).await()
  124. const keys = ['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek', 'skill-filesystem']
  125. expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)).toEqual(keys)
  126. expect(slots.entries('settings.agentPreset.plugin.item').map(entry => entry.options.key)).toEqual(keys)
  127. })
  128. it('dispatches the served namespaces its cards claim, and no others', async () => {
  129. // ui-theme is served but belongs to another surface, and a deployment
  130. // composing no PowerShell/POSIX executor serves no `bash` at all.
  131. const { ctx, slots } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek'])
  132. declareRoot(slots)
  133. await ctx.plugin({ inject: [...inject], apply }).await()
  134. const tab = slots.entries('settings.plugins.tab')[0]!
  135. const face = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
  136. await vi.waitFor(() => {
  137. expect(face.hooks.configurablePlugins.getSnapshot().namespaces)
  138. .toEqual(['agent-loop', 'web-search-deepseek'])
  139. })
  140. })
  141. it('re-reads the served namespaces when the Host commits a settings document', async () => {
  142. // Which namespaces the Host serves is a registration fact the wire never
  143. // announces on its own, so the tab rides the invalidation that can
  144. // accompany a changed composition.
  145. const { ctx, slots, describeSettings, remote } = await bench(['bash'])
  146. declareRoot(slots)
  147. await ctx.plugin({ inject: [...inject], apply }).await()
  148. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
  149. describeSettings.mockClear()
  150. remote.emit('settings/document-updated', ['bash', 1])
  151. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
  152. })
  153. it('re-reads the served namespaces after a reconnect', async () => {
  154. const { ctx, slots, describeSettings } = await bench(['bash'])
  155. declareRoot(slots)
  156. await ctx.plugin({ inject: [...inject], apply }).await()
  157. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
  158. describeSettings.mockClear()
  159. ctx.emit('connection/reset')
  160. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
  161. })
  162. it('re-reads the credential when the Host reports the watched reference changed', async () => {
  163. const { ctx, slots, describeCredentials, remote } = await bench()
  164. declareRoot(slots)
  165. await ctx.plugin({ inject: [...inject], apply }).await()
  166. await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
  167. describeCredentials.mockClear()
  168. // A key written on another surface changes no settings section, so this
  169. // event is the only thing that reaches the card.
  170. remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
  171. await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
  172. })
  173. it('refreshes the subagent catalog after model inputs change or the connection resets', async () => {
  174. const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog')
  175. const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection')
  176. const { ctx, slots, remote } = await bench(['subagent-model-selection'])
  177. declareRoot(slots)
  178. await ctx.plugin({ inject: [...inject], apply }).await()
  179. refresh.mockClear()
  180. reset.mockClear()
  181. remote.emit('llm/adapters-updated', [])
  182. expect(refresh).toHaveBeenCalledTimes(1)
  183. remote.emit('settings/document-updated', ['llm-deepseek', 1])
  184. expect(refresh).toHaveBeenCalledTimes(2)
  185. ctx.emit('connection/reset')
  186. expect(reset).toHaveBeenCalledTimes(1)
  187. })
  188. it('ignores a credential change for a reference no card watches', async () => {
  189. const { ctx, slots, describeCredentials, remote } = await bench()
  190. declareRoot(slots)
  191. await ctx.plugin({ inject: [...inject], apply }).await()
  192. await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
  193. describeCredentials.mockClear()
  194. remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY'])
  195. await Promise.resolve()
  196. expect(describeCredentials).not.toHaveBeenCalled()
  197. })
  198. it('switches every card to a preset scope while its detail page holds the selection', async () => {
  199. const { ctx, slots, describeSettings, remote } = await bench(['shell'])
  200. declareRoot(slots)
  201. await ctx.plugin({ inject: [...inject], apply }).await()
  202. const section = slots.entries('settings.agentPreset.detail')[0]!
  203. const face = (section.inject as unknown as () => PresetSettingsFace)()
  204. await vi.waitFor(() => { expect(face.hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['shell']) })
  205. // One controller serves both slots: the card under the preset slot is the tab's card.
  206. const bash = slots.entries('settings.agentPreset.plugin.item')[0]!
  207. const bashFace = (bash.inject as unknown as () => BashCardFace)()
  208. const tabBash = slots.entries('settings.plugin.item')[0]!
  209. expect((tabBash.inject as unknown as () => BashCardFace)().hooks.bashCard).toBe(bashFace.hooks.bashCard)
  210. await vi.waitFor(() => { expect(bashFace.hooks.bashCard.getSnapshot()).toMatchObject({ scope: undefined }) })
  211. describeSettings.mockClear()
  212. face.selectScope('preset/standard')
  213. // The scoped mirror is read on first selection, and the card follows it.
  214. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalledWith('preset/standard') })
  215. await vi.waitFor(() => {
  216. expect(bashFace.hooks.bashCard.getSnapshot()).toMatchObject({ scope: 'preset/standard' })
  217. })
  218. // A scoped commit reloads the scoped mirror only.
  219. describeSettings.mockClear()
  220. remote.emit('settings/document-updated', ['shell', 1, 'preset/standard'])
  221. await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalledTimes(1) })
  222. expect(describeSettings).toHaveBeenCalledWith('preset/standard')
  223. // Leaving the page returns every card to the global instance.
  224. face.selectScope(null)
  225. expect(bashFace.hooks.bashCard.getSnapshot()).toMatchObject({ scope: undefined })
  226. })
  227. it('registers into a declaration that arrives after apply', async () => {
  228. const { ctx, slots } = await bench()
  229. await ctx.plugin({ inject: [...inject], apply }).await()
  230. declareRoot(slots)
  231. await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
  232. })
  233. it('collapses every contribution on teardown', async () => {
  234. const { ctx, slots } = await bench()
  235. declareRoot(slots)
  236. const fiber = ctx.plugin({ inject: [...inject], apply })
  237. await fiber.await()
  238. expect(slots.entries('settings.plugin.item')).toHaveLength(5)
  239. expect(slots.entries('settings.agentPreset.plugin.item')).toHaveLength(5)
  240. await fiber.dispose()
  241. expect(slots.entries('settings.section')).toHaveLength(0)
  242. expect(slots.entries('settings.agentPreset.detail')).toHaveLength(0)
  243. expect(slots.spec('settings.plugins.tab')).toBeUndefined()
  244. expect(slots.spec('settings.plugin.item')).toBeUndefined()
  245. expect(slots.spec('settings.agentPreset.plugin.item')).toBeUndefined()
  246. })
  247. })