feat(web): add read-only Loader plugin inventory
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user