apply.client.spec.ts 12 KB

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