feat(web): add read-only Loader plugin inventory

This commit is contained in:
ZiyaZhang
2026-08-11 06:46:41 -07:00
parent 258331f0da
commit 6ed5df964e
64 changed files with 1429 additions and 19 deletions
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { Context, Service } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, NS } from '../src/client/index.ts'
import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx'
import type { PluginSettingsSectionInjected } from '../src/client/PluginSettingsSection.tsx'
usePinnedBrowserLanguages('zh-CN')
afterEach(cleanup)
const EMPTY = { entries: [] }
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
class RemoteService extends Service {
constructor(serviceCtx: Context) {
super(serviceCtx, 'remote')
}
}
new RemoteService(ctx)
const list = vi.fn(() => Promise.resolve(EMPTY))
ctx.provide('remote.pluginInventory', { list })
return { ctx, slots: ctx.get('slots') as SlotsService, locale, list }
}
function declare(slots: SlotsService): () => void {
return slots.register({
name: 'root',
children: { 'settings.section': { kind: 'list', scope: 'root' } },
} as never, () => null)
}
describe('ui-plugins browser plugin', () => {
it('declares only the services used by the Settings Remote contribution', () => {
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
})
it('registers a localized section without reading the Remote eagerly', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.section')[0]!
expect(entry.component).toBe(PluginSettingsSection)
expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 })
expect(entry.locale).toBe(NS)
expect(resolveSlotLabel(entry.options.label)).toBe('插件')
expect(b.list).not.toHaveBeenCalled()
const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)()
await expect(injected.list()).resolves.toEqual(EMPTY)
expect(b.list).toHaveBeenCalledOnce()
await b.ctx.fiber.dispose()
})
it('follows locale and recovers across late declaration and declarer reload', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.entries('settings.section')).toHaveLength(0)
const stop = declare(b.slots)
await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) })
b.locale.setLocale('en')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins')
stop()
expect(b.slots.entries('settings.section')).toHaveLength(0)
declare(b.slots)
await vi.waitFor(() => {
expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection)
})
await fiber.dispose()
expect(b.slots.entries('settings.section')).toHaveLength(0)
expect(() => b.locale.register(NS, 'zh', {})).not.toThrow()
await b.ctx.fiber.dispose()
})
})
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx'
import type {
PluginSettingsSectionInjected,
PluginSettingsSectionProps,
} from '../src/client/PluginSettingsSection.tsx'
import { en, type PluginsKey } from '../src/client/locales.ts'
afterEach(cleanup)
type Snapshot = Awaited<ReturnType<PluginSettingsSectionInjected['list']>>
const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t']
const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never
function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps {
return {
close: vi.fn(),
useSessions: unusedHook,
useWorkspaces: unusedHook,
t,
list,
}
}
const SNAPSHOT = {
entries: [
{ entryId: 'active', displayId: 'active-name', enabled: true, fiberPhase: 'active' },
{ entryId: 'pending', displayId: 'pending-name', enabled: true, fiberPhase: 'pending' },
{ entryId: 'loading', displayId: 'loading-name', enabled: true, fiberPhase: 'loading' },
{ entryId: 'failed', displayId: 'failed-name', enabled: true, fiberPhase: 'failed' },
{ entryId: 'unloading', displayId: 'unloading-name', enabled: true, fiberPhase: 'unloading' },
{ entryId: 'disabled-entry', displayId: 'disabled-name', enabled: false, fiberPhase: null },
],
} as unknown as Snapshot
describe('PluginSettingsSection', () => {
it('renders searchable two-column-card semantics with dots and tags', async () => {
const deferred = Promise.withResolvers<Snapshot>()
const list = vi.fn(() => deferred.promise)
const view = render(<PluginSettingsSection {...props(list)} />)
expect(screen.getByText(en.loading)).toBeTruthy()
await act(async () => { deferred.resolve(SNAPSHOT) })
expect(list).toHaveBeenCalledOnce()
expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy()
expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy()
expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6')
expect(screen.getAllByRole('listitem')).toHaveLength(6)
expect(screen.getAllByText(en.enabledTag)).toHaveLength(5)
expect(screen.getByText(en.disabledTag)).toBeTruthy()
for (const value of ['Active', 'Pending', 'Loading', 'Failed', 'Unloading', 'No root Fiber']) {
expect(screen.getByRole('img', { name: value })).toBeTruthy()
}
expect(screen.getByRole('listitem', { name: 'active-name, Active, Enabled' })).toBeTruthy()
})
it('filters by local id or Loader entry id', async () => {
render(<PluginSettingsSection {...props(async () => SNAPSHOT)} />)
const search = await screen.findByRole('searchbox', { name: en.search })
fireEvent.change(search, { target: { value: 'disabled-entry' } })
expect(screen.getAllByRole('listitem')).toHaveLength(1)
expect(screen.getByText('disabled-name')).toBeTruthy()
fireEvent.change(search, { target: { value: 'pending' } })
expect(screen.getAllByRole('listitem')).toHaveLength(1)
expect(screen.getByText('pending-name')).toBeTruthy()
fireEvent.change(search, { target: { value: 'not-a-plugin' } })
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
expect(screen.getByText(en.emptySearch)).toBeTruthy()
})
it('shows a generic failure and retries into the empty state', async () => {
const list = vi.fn<PluginSettingsSectionInjected['list']>()
.mockRejectedValueOnce(new Error('private transport detail'))
.mockResolvedValueOnce({ entries: [] })
render(<PluginSettingsSection {...props(list)} />)
expect((await screen.findByRole('alert')).textContent).toBe(en.error)
expect(screen.queryByText('private transport detail')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: en.retry }))
await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
expect(await screen.findByText(en.empty)).toBeTruthy()
})
it('contains a synchronous Remote failure and ignores a result after unmount', async () => {
const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginSettingsSectionInjected['list']
const failed = render(<PluginSettingsSection {...props(syncFailure)} />)
expect((await screen.findByRole('alert')).textContent).toBe(en.error)
failed.unmount()
const deferred = Promise.withResolvers<Snapshot>()
const pending = render(<PluginSettingsSection {...props(() => deferred.promise)} />)
pending.unmount()
await act(async () => { deferred.resolve(SNAPSHOT) })
})
})
@@ -0,0 +1,15 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as PluginsInvariant from '../src/invariant.ts'
describe('ui-plugins invariant companion', () => {
it('registers the empty installer and keeps the node half inert', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined()
const { apply } = await import('../src/index.ts')
apply()
await ctx.fiber.dispose()
})
})