cc-haha/desktop/src/__tests__/pluginsSettings.test.tsx
程序员阿江(Relakkes) de736c49f7 Unify plugin capabilities with the desktop management views
Desktop plugin details now route into the shared Skills, Agents, and MCP management surfaces instead of maintaining separate read-only drilldowns. This also extends the desktop/server skill aggregation so plugin-provided skills appear in the shared list, groups MCP entries by source, and preserves detail-view back navigation based on where the user entered the page.

The implementation keeps plugin detail as the high-level capability hub while pushing real inspection into the existing management pages. Disabled plugins no longer expose false navigation paths into shared views, and the agent-browser regression script was expanded to exercise the new end-to-end flows.

Constraint: Shared Agents data only includes enabled plugin agents, so disabled plugins cannot deep-link into agent detail
Rejected: Keep duplicating full Skills/Agents/MCP detail inside Plugin detail | creates divergent UI flows and stale data paths
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: If a detail view can be opened from multiple entry points, keep the return target in store state rather than hardcoding a single back destination
Tested: desktop vitest for plugins/skills/agents/mcp; desktop tsc --noEmit; desktop vite build; server skills API test; agent-browser web regression on plugin->skill/mcp and plugin->agent back navigation
Not-tested: packaged desktop app regression after rebuilding the Tauri bundle
2026-04-22 12:31:57 +08:00

445 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { usePluginStore } from '../stores/pluginStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
import { useUIStore } from '../stores/uiStore'
const MOCK_FETCH_SKILLS = vi.fn()
const MOCK_FETCH_SKILL_DETAIL = vi.fn()
const MOCK_FETCH_AGENTS = vi.fn()
const MOCK_FETCH_SERVERS = vi.fn()
vi.mock('../api/agents', () => ({
agentsApi: {
list: vi.fn().mockResolvedValue({ activeAgents: [], allAgents: [] }),
},
}))
vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
isLoading: false,
fetchProviders: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),
testProvider: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
testConfig: vi.fn(),
}),
}))
vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => <div>Adapter Settings Mock</div>,
}))
vi.mock('../stores/agentStore', () => ({
useAgentStore: Object.assign((selector?: (state: any) => unknown) => {
const state = {
activeAgents: [],
allAgents: [],
isLoading: false,
error: null,
selectedAgent: null,
fetchAgents: MOCK_FETCH_AGENTS,
selectAgent: vi.fn(),
}
return selector ? selector(state) : state
}, {
getState: () => ({
activeAgents: [],
allAgents: [],
isLoading: false,
error: null,
selectedAgent: null,
fetchAgents: MOCK_FETCH_AGENTS,
selectAgent: vi.fn(),
}),
}),
}))
vi.mock('../stores/skillStore', () => ({
useSkillStore: Object.assign((selector?: (state: any) => unknown) => {
const state = {
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: MOCK_FETCH_SKILLS,
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
clearSelection: vi.fn(),
}
return selector ? selector(state) : state
}, {
getState: () => ({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: MOCK_FETCH_SKILLS,
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
clearSelection: vi.fn(),
}),
}),
}))
vi.mock('../stores/mcpStore', () => ({
useMcpStore: Object.assign((selector?: (state: any) => unknown) => {
const state = {
servers: [],
selectedServer: null,
isLoading: false,
error: null,
fetchServers: MOCK_FETCH_SERVERS,
createServer: vi.fn(),
updateServer: vi.fn(),
deleteServer: vi.fn(),
toggleServer: vi.fn(),
reconnectServer: vi.fn(),
selectServer: vi.fn(),
}
return selector ? selector(state) : state
}, {
getState: () => ({
servers: [],
selectedServer: null,
isLoading: false,
error: null,
fetchServers: MOCK_FETCH_SERVERS,
createServer: vi.fn(),
updateServer: vi.fn(),
deleteServer: vi.fn(),
toggleServer: vi.fn(),
reconnectServer: vi.fn(),
selectServer: vi.fn(),
}),
}),
}))
const noop = vi.fn()
function switchToPluginsTab() {
fireEvent.click(screen.getByText('Plugins'))
}
describe('Settings > Plugins tab', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ pendingSettingsTab: null })
useSessionStore.setState({
sessions: [
{
id: 'session-1',
title: 'Active session',
createdAt: '2026-04-20T00:00:00.000Z',
modifiedAt: '2026-04-20T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
},
],
activeSessionId: 'session-1',
isLoading: false,
error: null,
})
usePluginStore.setState({
plugins: [],
marketplaces: [],
summary: { total: 0, enabled: 0, errorCount: 0, marketplaceCount: 0 },
selectedPlugin: null,
lastReloadSummary: null,
isLoading: false,
isDetailLoading: false,
isApplying: false,
error: null,
fetchPlugins: noop,
fetchPluginDetail: noop,
reloadPlugins: vi.fn().mockResolvedValue({
enabled: 1,
disabled: 0,
skills: 2,
agents: 1,
hooks: 0,
mcpServers: 1,
lspServers: 0,
errors: 0,
}),
enablePlugin: vi.fn().mockResolvedValue('enabled'),
disablePlugin: vi.fn().mockResolvedValue('disabled'),
updatePlugin: vi.fn().mockResolvedValue('updated'),
uninstallPlugin: vi.fn().mockResolvedValue('uninstalled'),
clearSelection: vi.fn(),
})
})
it('renders plugin browser summary and grouped cards', () => {
usePluginStore.setState({
plugins: [
{
id: 'github@claude-plugins-official',
name: 'github',
marketplace: 'claude-plugins-official',
scope: 'user',
enabled: true,
hasErrors: false,
isBuiltin: false,
version: '1.2.3',
description: 'GitHub integration',
authorName: 'Anthropic',
componentCounts: {
commands: 1,
agents: 1,
skills: 2,
hooks: 0,
mcpServers: 1,
lspServers: 0,
},
errors: [],
},
{
id: 'pyright-lsp@claude-plugins-official',
name: 'pyright-lsp',
marketplace: 'claude-plugins-official',
scope: 'project',
enabled: false,
hasErrors: true,
isBuiltin: false,
description: 'Python language tooling',
componentCounts: {
commands: 0,
agents: 0,
skills: 0,
hooks: 0,
mcpServers: 0,
lspServers: 1,
},
errors: ['Executable not found in $PATH'],
},
],
marketplaces: [
{
name: 'claude-plugins-official',
source: 'github:anthropics/claude-plugins-official',
autoUpdate: true,
installedCount: 2,
},
],
summary: { total: 2, enabled: 1, errorCount: 1, marketplaceCount: 1 },
})
render(<Settings />)
switchToPluginsTab()
expect(screen.getByText('Browse installed plugins')).toBeInTheDocument()
expect(screen.getByText('Plugin Manager')).toBeInTheDocument()
expect(screen.getAllByText('Needs attention').length).toBeGreaterThan(0)
expect(screen.getByText('github')).toBeInTheDocument()
expect(screen.getByText('Python language tooling')).toBeInTheDocument()
expect(screen.getByText('Known marketplaces')).toBeInTheDocument()
})
it('renders plugin detail with bundled capability sections', () => {
usePluginStore.setState({
selectedPlugin: {
id: 'github@claude-plugins-official',
name: 'github',
marketplace: 'claude-plugins-official',
scope: 'user',
enabled: true,
hasErrors: false,
isBuiltin: false,
version: '1.2.3',
description: 'GitHub integration',
authorName: 'Anthropic',
installPath: '/Users/test/.claude/plugins/cache/github',
componentCounts: {
commands: 1,
agents: 1,
skills: 2,
hooks: 1,
mcpServers: 1,
lspServers: 0,
},
capabilities: {
commands: ['review-pr'],
agents: ['pr-reviewer'],
skills: ['commit', 'create-pr'],
hooks: ['SessionStart'],
mcpServers: ['github-api'],
lspServers: [],
},
commandEntries: [
{
name: 'review-pr',
description: 'Review the current pull request.',
},
],
agentEntries: [
{
name: 'pr-reviewer',
description: 'Review pull request quality and risk.',
},
],
hookEntries: [
{
event: 'SessionStart',
matcher: 'Write',
actions: ['echo preparing plugin runtime'],
},
],
skillEntries: [
{
name: 'create-pr',
description: 'Create a pull request from the current branch.',
},
{
name: 'commit',
description: 'Commit the current staged changes.',
version: '1.0.0',
},
],
mcpServerEntries: [
{
name: 'plugin:github:github-api',
displayName: 'github-api',
transport: 'http',
summary: 'https://api.github.com/mcp',
},
],
errors: [],
},
})
render(<Settings />)
switchToPluginsTab()
expect(screen.getByText('Plugin Detail')).toBeInTheDocument()
expect(screen.getByText('GitHub integration')).toBeInTheDocument()
expect(screen.getByText('Bundled capabilities')).toBeInTheDocument()
expect(screen.getByText('/review-pr')).toBeInTheDocument()
expect(screen.getByText('Review pull request quality and risk.')).toBeInTheDocument()
expect(screen.getByText('echo preparing plugin runtime')).toBeInTheDocument()
expect(screen.getByText('Create a pull request from the current branch.')).toBeInTheDocument()
expect(screen.getByText('https://api.github.com/mcp')).toBeInTheDocument()
expect(screen.getByText('Apply changes')).toBeInTheDocument()
expect(screen.getByText('Uninstall')).toBeInTheDocument()
})
it('navigates plugin skills into the shared Skills page flow', () => {
usePluginStore.setState({
selectedPlugin: {
id: 'telegram@claude-plugins-official',
name: 'telegram',
marketplace: 'claude-plugins-official',
scope: 'user',
enabled: true,
hasErrors: false,
isBuiltin: false,
description: 'Telegram integration',
componentCounts: {
commands: 0,
agents: 0,
skills: 1,
hooks: 0,
mcpServers: 0,
lspServers: 0,
},
capabilities: {
commands: [],
agents: [],
skills: ['telegram:access'],
hooks: [],
mcpServers: [],
lspServers: [],
},
commandEntries: [],
agentEntries: [],
hookEntries: [],
skillEntries: [
{
name: 'telegram:access',
displayName: 'access',
description: 'Manage Telegram access.',
pluginName: 'telegram',
},
],
mcpServerEntries: [],
errors: [],
},
})
render(<Settings />)
switchToPluginsTab()
fireEvent.click(screen.getByText('access'))
expect(MOCK_FETCH_SKILL_DETAIL).toHaveBeenCalledWith('plugin', 'telegram:access', '/workspace/project', 'plugins')
})
it('disables shared navigation cards for disabled plugins', () => {
usePluginStore.setState({
selectedPlugin: {
id: 'codex@openai-codex',
name: 'codex',
marketplace: 'openai-codex',
scope: 'user',
enabled: false,
hasErrors: false,
isBuiltin: false,
description: 'Use Codex from Claude Code',
componentCounts: {
commands: 0,
agents: 1,
skills: 1,
hooks: 0,
mcpServers: 0,
lspServers: 0,
},
capabilities: {
commands: [],
agents: ['codex:codex-rescue'],
skills: ['codex:gpt-5-4-prompting'],
hooks: [],
mcpServers: [],
lspServers: [],
},
commandEntries: [],
agentEntries: [
{
name: 'codex:codex-rescue',
displayName: 'codex-rescue',
description: 'Delegate to Codex.',
},
],
hookEntries: [],
skillEntries: [
{
name: 'codex:gpt-5-4-prompting',
displayName: 'gpt-5-4-prompting',
description: 'Prompting guide.',
},
],
mcpServerEntries: [],
errors: [],
},
})
render(<Settings />)
switchToPluginsTab()
expect(screen.getAllByText('Enable this plugin and apply changes before opening its skills, agents, or MCP entries in the shared management pages.').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: /codex-rescue/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /gpt-5-4-prompting/i })).toBeDisabled()
})
})