browser-plugin.client.spec.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. // @vitest-environment jsdom
  2. import { Context, Service } from '@deepseek-ai/cordis'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { cleanup, render } from '@testing-library/react'
  5. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  6. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  7. import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
  8. import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
  9. import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts'
  10. import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
  11. import { PluginsPanelIcon } from '../src/client/PluginsPanelIcon.tsx'
  12. import type { PluginManagerFace } from '../src/client/manager-store.ts'
  13. import { apply as hostApply } from '../src/index.ts'
  14. usePinnedBrowserLanguages('zh-CN')
  15. afterEach(cleanup)
  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. class LocaleHolder extends Service {
  22. constructor(serviceCtx: Context) {
  23. super(serviceCtx, 'localeHolder')
  24. }
  25. }
  26. new LocaleHolder(ctx)
  27. const list = vi.fn(() => Promise.resolve({ ok: true as const, value: { entries: [], managementAvailable: true } }))
  28. const remote = new TestRemote(ctx, {
  29. pluginInventory: { list },
  30. pluginManager: {
  31. listBundles: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })),
  32. listPlugins: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })),
  33. },
  34. })
  35. return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote }
  36. }
  37. function declare(slots: SlotRegistry): () => void {
  38. return slots.register({
  39. name: 'root',
  40. children: {
  41. 'main': { kind: 'keyed', scope: 'root' },
  42. 'sidebar.panellist': { kind: 'list', scope: 'root' },
  43. },
  44. } as never, () => null)
  45. }
  46. describe('ui-plugin-manager browser plugin', () => {
  47. it('keeps the host Loader entry inert', () => {
  48. expect(hostApply).not.toThrow()
  49. })
  50. it('declares only the services the page and its Remote methods use', () => {
  51. expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory'])
  52. })
  53. it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => {
  54. const b = await bench()
  55. declare(b.slots)
  56. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  57. await fiber.await()
  58. const entry = b.slots.entries('main')[0]!
  59. expect(entry.component).toBe(PluginManagerPage)
  60. expect(entry.options).toMatchObject({ key: PANEL_ID })
  61. expect(entry.locale).toBe(NS)
  62. // The sidebar entry addresses the page by the same id and speaks the dictionary.
  63. const icon = b.slots.entries('sidebar.panellist')[0]!
  64. expect(icon.component).toBe(PluginsPanelIcon)
  65. const unread = () => { throw new Error('The sidebar icon must not read application state') }
  66. const glyph = render(<PluginsPanelIcon size={18} active={false}
  67. usePanelInfo={unread} useSessions={unread} useSessionStatus={unread} useSessionRetainInfo={unread}
  68. useWorkspaces={unread} useResource={unread} />)
  69. expect(glyph.container.querySelector('svg')?.getAttribute('width')).toBe('18')
  70. expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 })
  71. expect(icon.locale).toBe(NS)
  72. expect(resolveSlotLabel(icon.options.label)).toBe('插件')
  73. // The page declares the slots a plugin's configuration arrives through, and binds their projection beside its state.
  74. expect(b.slots.spec('plugins.item')).toMatchObject({ kind: 'list', scope: 'root' })
  75. expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' })
  76. expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' })
  77. const face = (entry.inject as unknown as () => PluginManagerFace)()
  78. expect(face.hooks.configLedger.getSnapshot()).toEqual({ items: [], bundles: new Set(), rows: new Set() })
  79. // A Host change before the first render is not a reason to read.
  80. b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
  81. b.ctx.emit('connection/reset')
  82. await Promise.resolve()
  83. expect(b.list).not.toHaveBeenCalled()
  84. face.ensure()
  85. await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') })
  86. expect(b.list).toHaveBeenCalledTimes(1)
  87. b.remote.emit('plugin-manager/changed', [{ reason: 'bundle' }])
  88. await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) })
  89. b.ctx.emit('connection/reset')
  90. await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(3) })
  91. // Install output folds into an open run only.
  92. face.openInstall()
  93. face.editInstallSpec('pkg')
  94. b.remote.emit('plugin-manager/install-log', [{ jobId: 'j', argv: ['pnpm', 'add', 'pkg'], cwd: '/p', stream: 'stdout', text: 'early' }])
  95. b.remote.emit('plugin-manager/install-state', [{ requestId: 'foreign', phase: 'installing' }])
  96. expect(face.hooks.pluginManager.getSnapshot().install.runs).toEqual([])
  97. await fiber.dispose()
  98. expect(b.slots.entries('main')).toHaveLength(0)
  99. expect(b.slots.entries('sidebar.panellist')).toHaveLength(0)
  100. b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
  101. await Promise.resolve()
  102. expect(b.list).toHaveBeenCalledTimes(3)
  103. })
  104. })