a53769955b
Add an in-app plugin marketplace fed by a remote web catalog: the host plugin-inventory gateway gains marketplaceList/Install/Uninstall remotes, a durable per-user install table reconciled against the actual profile, and git/npm/tarball/bundle install paths with non-interactive git and vendored pnpm. The Web Settings surface gains a sibling 插件市场 tab that lists the catalog with recommended badges, repository links, and a combined sort (recommended first, then catalog priority, then id). Add a host + client skill manager: list local skills by direct filesystem discovery, and install/uninstall/toggle/edit their SKILL.md from git/npm/ tarball/local sources in the writable user root. Co-Authored-By: Claude <noreply@anthropic.com>
129 lines
6.3 KiB
TypeScript
129 lines
6.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
PluginMarketplaceSettingsTab,
|
|
type PluginMarketplaceSettingsTabInjected,
|
|
type PluginMarketplaceSettingsTabProps,
|
|
} from '../src/client/PluginMarketplaceSettingsTab.tsx'
|
|
import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
|
|
|
|
afterEach(cleanup)
|
|
|
|
const t = ((key: PluginInventoryLocaleKey): string => en[key]) as PluginMarketplaceSettingsTabProps['t']
|
|
|
|
function props(
|
|
marketplaceList: PluginMarketplaceSettingsTabInjected['marketplaceList'],
|
|
marketplaceInstall: PluginMarketplaceSettingsTabInjected['marketplaceInstall'] = async () => ({ ok: true as const, restartRequired: true }),
|
|
marketplaceUninstall: PluginMarketplaceSettingsTabInjected['marketplaceUninstall'] = async () => ({ ok: true as const, restartRequired: true }),
|
|
): PluginMarketplaceSettingsTabProps {
|
|
return { t, marketplaceList, marketplaceInstall, marketplaceUninstall } as PluginMarketplaceSettingsTabProps
|
|
}
|
|
|
|
describe('PluginMarketplaceSettingsTab', () => {
|
|
it('renders marketplace entries with a recommended badge and installs one', async () => {
|
|
const list = vi.fn(async () => ({
|
|
entries: [
|
|
{ id: 'a', name: 'Alpha', description: 'desc', author: 'Dev', repository: 'https://github.com/dev/alpha', installed: false, recommended: true },
|
|
{ id: 'b', name: 'Beta', description: '', installed: false },
|
|
],
|
|
}))
|
|
const install = vi.fn(async () => ({ ok: true as const, restartRequired: true }))
|
|
render(<PluginMarketplaceSettingsTab {...props(list, install)} />)
|
|
|
|
expect(await screen.findByRole('heading', { name: en.marketplace })).toBeTruthy()
|
|
expect(await screen.findByText('Alpha')).toBeTruthy()
|
|
expect(screen.getByText('desc')).toBeTruthy()
|
|
expect(screen.getByText('Dev')).toBeTruthy()
|
|
// The repository link opens the plugin's source in a new tab.
|
|
const repoLink = screen.getByText(en.marketplaceRepo).closest('a')!
|
|
expect(repoLink).toHaveProperty('href', 'https://github.com/dev/alpha')
|
|
// Only the recommended entry carries the badge.
|
|
expect(screen.getAllByText(en.recommended)).toHaveLength(1)
|
|
expect(screen.getByText('Beta')).toBeTruthy()
|
|
// Click the install button on Alpha's card specifically.
|
|
const alphaCard = screen.getByText('Alpha').closest('li')!
|
|
fireEvent.click(within(alphaCard).getByRole('button', { name: en.install }))
|
|
expect(install).toHaveBeenCalledWith('a')
|
|
expect(await screen.findByText(en.restartRequired)).toBeTruthy()
|
|
})
|
|
|
|
it('sorts recommended first, then by priority, then id', async () => {
|
|
const list = vi.fn(async () => ({
|
|
entries: [
|
|
{ id: 'c', name: 'Charlie', description: '', recommended: true, priority: 1, installed: false },
|
|
{ id: 'a', name: 'Alpha', description: '', recommended: true, priority: 3, installed: false },
|
|
// Highest priority of all, but not recommended: it still sorts last.
|
|
{ id: 'b', name: 'Beta', description: '', recommended: false, priority: 100, installed: false },
|
|
],
|
|
}))
|
|
render(<PluginMarketplaceSettingsTab {...props(list)} />)
|
|
const items = await screen.findAllByRole('listitem')
|
|
const names = items.map(item => item.querySelector('strong')?.textContent)
|
|
expect(names).toEqual(['Alpha', 'Charlie', 'Beta'])
|
|
})
|
|
|
|
it('uninstalls an installed marketplace entry', async () => {
|
|
const list = vi.fn(async () => ({
|
|
entries: [{ id: 'a', name: 'Alpha', description: '', installed: true }],
|
|
}))
|
|
const uninstall = vi.fn(async () => ({ ok: true as const, restartRequired: true }))
|
|
render(<PluginMarketplaceSettingsTab {...props(list, undefined, uninstall)} />)
|
|
|
|
expect(await screen.findByText(en.marketplaceInstalled)).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: en.uninstall }))
|
|
expect(uninstall).toHaveBeenCalledWith('a')
|
|
expect(await screen.findByText(en.restartRequired)).toBeTruthy()
|
|
})
|
|
|
|
it('pauses for build consent and retries with the approved set', async () => {
|
|
const list = vi.fn(async () => ({
|
|
entries: [{ id: 'a', name: 'Alpha', description: '', installed: false }],
|
|
}))
|
|
const install = vi.fn()
|
|
.mockResolvedValueOnce({ ok: true, restartRequired: false, pendingBuilds: ['node-pty', 'protobufjs'] })
|
|
.mockResolvedValueOnce({ ok: true, restartRequired: true })
|
|
render(<PluginMarketplaceSettingsTab {...props(list, install)} />)
|
|
|
|
await screen.findByText('Alpha')
|
|
fireEvent.click(screen.getByRole('button', { name: en.install }))
|
|
expect(await screen.findByRole('dialog', { name: en.consentTitle })).toBeTruthy()
|
|
expect(screen.getByText('node-pty')).toBeTruthy()
|
|
expect(screen.getByText('protobufjs')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: en.consentAllow }))
|
|
expect(install).toHaveBeenLastCalledWith('a', ['node-pty', 'protobufjs'])
|
|
expect(await screen.findByText(en.restartRequired)).toBeTruthy()
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
})
|
|
|
|
it('shows a marketplace load failure with a retry', async () => {
|
|
const list = vi.fn()
|
|
.mockRejectedValueOnce(new Error('network miss'))
|
|
.mockResolvedValueOnce({ entries: [] })
|
|
render(<PluginMarketplaceSettingsTab {...props(list)} />)
|
|
|
|
expect((await screen.findByRole('alert')).textContent).toBe(en.marketplaceLoadFailed)
|
|
fireEvent.click(screen.getByRole('button', { name: en.retry }))
|
|
await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
|
|
expect(await screen.findByText(en.marketplaceEmpty)).toBeTruthy()
|
|
})
|
|
|
|
it('refreshes the marketplace via the header refresh button', async () => {
|
|
const list = vi.fn()
|
|
.mockResolvedValueOnce({ entries: [{ id: 'a', name: 'Alpha', description: '', installed: false }] })
|
|
.mockResolvedValueOnce({
|
|
entries: [
|
|
{ id: 'a', name: 'Alpha', description: '', installed: false },
|
|
{ id: 'b', name: 'Beta', description: '', installed: false },
|
|
],
|
|
})
|
|
render(<PluginMarketplaceSettingsTab {...props(list)} />)
|
|
|
|
await screen.findByText('Alpha')
|
|
expect(screen.queryByText('Beta')).toBeNull()
|
|
fireEvent.click(screen.getByRole('button', { name: en.marketplaceRefresh }))
|
|
await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
|
|
expect(await screen.findByText('Beta')).toBeTruthy()
|
|
})
|
|
})
|