browser-plugin.client.spec.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. // @vitest-environment jsdom
  2. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  3. import type { ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
  4. import { Context, Service } from '@deepseek-ai/cordis'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { cleanup } from '@testing-library/react'
  7. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  8. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  9. import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
  10. import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
  11. import { apply, inject, NS } from '../src/client/index.ts'
  12. import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
  13. import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client'
  14. import type { PluginInventorySettingsTabInjected } from '../src/client/PluginInventorySettingsTab.tsx'
  15. import { apply as hostApply } from '../src/index.ts'
  16. usePinnedBrowserLanguages('zh-CN')
  17. afterEach(cleanup)
  18. const EMPTY = { entries: [] }
  19. type ListResult =
  20. | { readonly ok: true; readonly value: typeof EMPTY }
  21. | { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
  22. async function bench() {
  23. const ctx = new Context()
  24. const retryClient = vi.fn(async () => {})
  25. ctx.provide('modules', { entries: { state: createSnapshotStore({ syncing: false, failures: [] }), retry: retryClient } } as unknown as ClientModuleLoader)
  26. await ctx.plugin(SlotRegistry).await()
  27. const locale = new LocaleRuntime(ctx)
  28. ctx.provide('locale', locale)
  29. class RemoteService extends Service {
  30. constructor(serviceCtx: Context) {
  31. super(serviceCtx, 'remote')
  32. }
  33. }
  34. new RemoteService(ctx)
  35. const list = vi.fn<() => Promise<ListResult>>()
  36. .mockResolvedValue({ ok: true, value: EMPTY })
  37. ctx.provide('remote.pluginInventory', { list })
  38. const listBundles = vi.fn().mockResolvedValue({ ok: true, value: [] })
  39. const managed = {
  40. listBundles, listPlugins: vi.fn().mockResolvedValue({ ok: true, value: [] }),
  41. setPluginEnabled: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', stage: 'enable', target: 'plugin' } }),
  42. setBundleEnabled: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', stage: 'enable', target: 'bundle' } }),
  43. installBundle: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'restart-required', stage: 'install', target: 'install' } }),
  44. removeBundle: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', stage: 'remove', target: 'remove' } }),
  45. }
  46. ctx.provide('remote.pluginManager', managed)
  47. return { ctx, retryClient, slots: ctx.get('slots') as SlotRegistry, locale, list, listBundles, managed }
  48. }
  49. function declare(slots: SlotRegistry): () => void {
  50. return slots.register({
  51. name: 'root',
  52. children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
  53. } as never, () => null)
  54. }
  55. describe('ui-settings-plugin-inventory browser plugin', () => {
  56. it('keeps the host Loader entry inert', () => {
  57. expect(hostApply).not.toThrow()
  58. })
  59. it('declares only the services used by the Settings Remote contribution', () => {
  60. expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory', 'remote.pluginManager', 'modules'])
  61. })
  62. it('registers a localized tab without reading the Remote eagerly', async () => {
  63. const b = await bench()
  64. declare(b.slots)
  65. await b.ctx.plugin({ inject: [...inject], apply }).await()
  66. const entry = b.slots.entries('settings.plugins.tab')[0]!
  67. expect(entry.component).toBe(PluginInventorySettingsTab)
  68. expect(entry.options).toMatchObject({ id: 'all', order: 10 })
  69. expect(entry.locale).toBe(NS)
  70. expect(resolveSlotLabel(entry.options.label)).toBe('插件列表')
  71. expect(b.list).not.toHaveBeenCalled()
  72. const injected = (entry.inject as unknown as () => PluginInventorySettingsTabInjected)()
  73. injected.retryClient()
  74. const retryError = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {})
  75. b.retryClient.mockRejectedValueOnce(new Error('retry unavailable'))
  76. injected.retryClient()
  77. await vi.waitFor(() => { expect(retryError).toHaveBeenCalled() })
  78. retryError.mockRestore()
  79. await expect(injected.list()).resolves.toEqual(EMPTY)
  80. expect(b.list).toHaveBeenCalledOnce()
  81. await expect(injected.management?.listBundles()).resolves.toEqual([])
  82. expect(b.listBundles).toHaveBeenCalledOnce()
  83. const manager = injected.management!
  84. await expect(manager.listPlugins()).resolves.toEqual([])
  85. await expect(manager.setPluginEnabled('include:plugin' as PluginEntryId, false)).resolves.toMatchObject({ stage: 'enable', target: 'plugin' })
  86. expect(b.managed.setPluginEnabled).toHaveBeenCalledWith('include:plugin', false)
  87. await expect(manager.setBundleEnabled('extra', false)).resolves.toMatchObject({ stage: 'enable', target: 'bundle' })
  88. expect(b.managed.setBundleEnabled).toHaveBeenCalledWith('extra', false)
  89. await expect(manager.installBundle('extra@1', { enabled: false })).resolves.toMatchObject({ application: 'restart-required' })
  90. expect(b.managed.installBundle).toHaveBeenCalledWith('extra@1', { enabled: false })
  91. await expect(manager.removeBundle('extra')).resolves.toMatchObject({ stage: 'remove', target: 'remove' })
  92. expect(b.managed.removeBundle).toHaveBeenCalledWith('extra')
  93. b.listBundles.mockResolvedValueOnce({ ok: false, error: new Error('manager unavailable') })
  94. await expect(manager.listBundles()).rejects.toThrow('manager unavailable')
  95. b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } })
  96. await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable')
  97. // Shipped preset names resolve over the agent-preset dictionaries the
  98. // real plugin registers; user-authored metadata stays untranslated.
  99. b.locale.register('settings.agentPreset', 'zh', { presetStandardName: '标准模式' } as never)
  100. expect(injected.presetName({ id: 'standard', trust: 'system', isDefault: true, rows: [] })).toBe('标准模式')
  101. expect(injected.presetName({ id: 'mine', trust: 'user', name: '我自己的', isDefault: false, rows: [] })).toBe('我自己的')
  102. await b.ctx.fiber.dispose()
  103. })
  104. it('follows locale and recovers across late declaration and declarer reload', async () => {
  105. const b = await bench()
  106. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  107. await fiber.await()
  108. expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
  109. const stop = declare(b.slots)
  110. await vi.waitFor(() => { expect(b.slots.entries('settings.plugins.tab')).toHaveLength(1) })
  111. b.locale.setLocale('en')
  112. expect(resolveSlotLabel(b.slots.entries('settings.plugins.tab')[0]!.options.label)).toBe('Plugin list')
  113. stop()
  114. expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
  115. declare(b.slots)
  116. await vi.waitFor(() => {
  117. expect(b.slots.entries('settings.plugins.tab')[0]?.component).toBe(PluginInventorySettingsTab)
  118. })
  119. await fiber.dispose()
  120. expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
  121. expect(() => b.locale.register(NS, 'zh', {})).not.toThrow()
  122. await b.ctx.fiber.dispose()
  123. })
  124. })