feat: unify plugin workspace navigation
This commit is contained in:
287
tests/unit/plugins-page.test.tsx
Normal file
287
tests/unit/plugins-page.test.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PluginsView } from '@/pages/Plugins';
|
||||
import type {
|
||||
PluginWorkspaceFilters,
|
||||
PluginWorkspaceItem,
|
||||
PluginWorkspaceProjection,
|
||||
} from '@/pages/Plugins/plugin-workspace-model';
|
||||
|
||||
const filters: PluginWorkspaceFilters = {
|
||||
scope: 'project', source: 'all', state: 'all', search: '', selectedKey: null,
|
||||
};
|
||||
|
||||
const projectPlugin = {
|
||||
id: 'makelore.data-service',
|
||||
version: '1.0.0',
|
||||
contractVersion: 1,
|
||||
requiresBackend: true,
|
||||
displayName: '开发数据服务',
|
||||
description: '为当前项目提供 JSON 数据。',
|
||||
enabled: true,
|
||||
state: 'configuration_required' as const,
|
||||
backend: { status: 'unconfigured' as const },
|
||||
skills: [{ id: 'data-service', assignedAgentIds: ['agent-a'] }],
|
||||
capabilities: [{
|
||||
id: 'data-service.documents',
|
||||
operations: [{
|
||||
id: 'put_document',
|
||||
billing: { mode: 'included' as const, availability: 'available' as const, notice: 'Fixed quotas apply' },
|
||||
tool: {
|
||||
name: 'data_service_put_document',
|
||||
label: '写入文档',
|
||||
description: '写入项目数据',
|
||||
mutation: 'write' as const,
|
||||
permissions: ['project.data.write'],
|
||||
},
|
||||
}],
|
||||
}],
|
||||
settingsSurface: 'data-service',
|
||||
};
|
||||
|
||||
const officialItem: PluginWorkspaceItem = {
|
||||
key: 'official:makelore.data-service',
|
||||
source: 'official',
|
||||
pluginId: 'makelore.data-service',
|
||||
packageId: null,
|
||||
title: '开发数据服务',
|
||||
summary: '为当前项目提供 JSON 数据。',
|
||||
category: '系统',
|
||||
tags: ['data'],
|
||||
publisher: 'MakeLore',
|
||||
version: '1.0.0',
|
||||
delivery: 'system_included',
|
||||
projectState: 'enabled',
|
||||
localEnabled: null,
|
||||
assignedAgentIds: ['agent-a'],
|
||||
assignedAgentNames: ['小明'],
|
||||
billing: 'included',
|
||||
updateAvailable: false,
|
||||
unavailable: false,
|
||||
stale: false,
|
||||
commands: [
|
||||
{ kind: 'disable_project', projectId: 'project-a', pluginId: 'makelore.data-service' },
|
||||
{ kind: 'open_agent_assignment', projectId: 'project-a', pluginId: 'makelore.data-service' },
|
||||
{ kind: 'open_settings', projectId: 'project-a', pluginId: 'makelore.data-service' },
|
||||
],
|
||||
official: {
|
||||
catalog: null,
|
||||
library: null,
|
||||
installation: null,
|
||||
project: projectPlugin,
|
||||
detail: {
|
||||
pluginId: 'makelore.data-service',
|
||||
title: '开发数据服务',
|
||||
summary: '为当前项目提供 JSON 数据。',
|
||||
category: '系统',
|
||||
tags: ['data'],
|
||||
providerDisplayName: 'MakeLore',
|
||||
runtimeKind: 'bundled_typed',
|
||||
runtimeStatus: 'enabled',
|
||||
acquisition: 'system_included',
|
||||
usageBilling: 'included',
|
||||
includedOperationCount: 1,
|
||||
meteredOperationCount: 0,
|
||||
descriptionMarkdown: '详细的数据服务介绍。',
|
||||
permissions: ['读取项目名称'],
|
||||
operations: [],
|
||||
stableVersion: '1.0.0',
|
||||
betaVersion: null,
|
||||
stableRelease: { releaseId: 'data-1', version: '1.0.0' },
|
||||
betaRelease: null,
|
||||
etag: 'data-1',
|
||||
pricingVersionId: null,
|
||||
stale: false,
|
||||
fetchedAt: 1,
|
||||
},
|
||||
},
|
||||
local: null,
|
||||
};
|
||||
|
||||
const localItem: PluginWorkspaceItem = {
|
||||
key: 'local:pi-web-search',
|
||||
source: 'local',
|
||||
pluginId: 'pi-web-search',
|
||||
packageId: 'pi-web-search',
|
||||
title: 'Pi Web Search',
|
||||
summary: 'npm:pi-web-search',
|
||||
category: null,
|
||||
tags: ['search'],
|
||||
publisher: null,
|
||||
version: '1.2.3',
|
||||
delivery: 'local_installed',
|
||||
projectState: 'not_applicable',
|
||||
localEnabled: true,
|
||||
assignedAgentIds: [],
|
||||
assignedAgentNames: [],
|
||||
billing: 'none',
|
||||
updateAvailable: false,
|
||||
unavailable: false,
|
||||
stale: false,
|
||||
commands: [
|
||||
{ kind: 'disable_local', packageId: 'pi-web-search' },
|
||||
{ kind: 'remove_local', packageId: 'pi-web-search' },
|
||||
],
|
||||
official: null,
|
||||
local: {
|
||||
schemaVersion: 1,
|
||||
packageId: 'pi-web-search',
|
||||
displayName: 'Pi Web Search',
|
||||
resolvedVersion: '1.2.3',
|
||||
source: { kind: 'npm', requested: 'npm:pi-web-search', resolved: 'npm:pi-web-search@1.2.3' },
|
||||
kind: 'mixed',
|
||||
skillEntries: [{ id: 'search', entryPath: 'skills/search/SKILL.md' }],
|
||||
extensionEntries: ['extensions/search.ts'],
|
||||
enabled: true,
|
||||
confirmedExecutableCode: true,
|
||||
installedAt: '2026-09-03T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
function projection(selected: PluginWorkspaceItem | null = null): PluginWorkspaceProjection {
|
||||
return {
|
||||
items: [officialItem, localItem],
|
||||
selected,
|
||||
counts: { all: 2, available: 0, mine: 2, enabled: 2, update: 0, unavailable: 0 },
|
||||
notices: [],
|
||||
};
|
||||
}
|
||||
|
||||
function props(selected: PluginWorkspaceItem | null = null) {
|
||||
return {
|
||||
projection: projection(selected),
|
||||
filters: { ...filters, selectedKey: selected?.key ?? null },
|
||||
activeProjectId: 'project-a',
|
||||
activeProjectName: 'Project A',
|
||||
sourceErrors: [],
|
||||
sourceLoading: [],
|
||||
dataService: null,
|
||||
dataServicePending: {},
|
||||
onFiltersChange: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
onCommand: vi.fn(),
|
||||
isCommandPending: vi.fn().mockReturnValue(false),
|
||||
onConfigureDataService: vi.fn(),
|
||||
onResetDataService: vi.fn(),
|
||||
onRemoveDataServiceCollection: vi.fn(),
|
||||
onRemoveDataServiceProject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('PluginsView', () => {
|
||||
it('renders the unified workspace and exposes URL-backed filter changes', () => {
|
||||
const view = props();
|
||||
view.sourceErrors = [
|
||||
{ source: 'catalog', message: 'catalog offline' },
|
||||
{ source: 'project', message: 'project unavailable' },
|
||||
];
|
||||
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
|
||||
|
||||
expect(screen.getByRole('heading', { name: '插件' })).toBeVisible();
|
||||
expect(screen.getByText(/MakeLore 运营发布官方插件/)).toBeVisible();
|
||||
expect(screen.getByText(/获取、项目启用与伙伴分配彼此独立/)).toBeVisible();
|
||||
expect(screen.getByText('开发数据服务')).toBeVisible();
|
||||
expect(screen.getByText('Pi Web Search')).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: '本机全局生效' })).toBeVisible();
|
||||
expect(screen.getByText('本机全局已启用')).toBeVisible();
|
||||
expect(screen.getByText('随 MakeLore 提供')).toBeVisible();
|
||||
expect(screen.getByText('已分配 1 位伙伴')).toBeVisible();
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(2);
|
||||
expect(screen.queryByRole('textbox', { name: /包|路径|source/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/通过对话安装/)).toBeVisible();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('范围'), { target: { value: 'all' } });
|
||||
expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ scope: 'all' }));
|
||||
fireEvent.change(screen.getByLabelText('来源'), { target: { value: 'local' } });
|
||||
expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ source: 'local' }));
|
||||
fireEvent.change(screen.getByLabelText('状态'), { target: { value: 'enabled' } });
|
||||
expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ state: 'enabled' }));
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: '搜索插件' }), { target: { value: 'web' } });
|
||||
fireEvent.submit(screen.getByRole('search'));
|
||||
expect(view.onFiltersChange).toHaveBeenCalledWith(expect.objectContaining({ search: 'web' }));
|
||||
});
|
||||
|
||||
it('shows one dialog detail surface, confirms destructive commands, and embeds Data Service settings', () => {
|
||||
const view = props(officialItem);
|
||||
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
|
||||
expect(dialog).toHaveTextContent('详细的数据服务介绍。');
|
||||
expect(dialog).toHaveTextContent('读取项目名称');
|
||||
expect(dialog).toHaveTextContent('data-service.documents');
|
||||
expect(dialog).toHaveTextContent('按平台包含');
|
||||
expect(dialog).toHaveTextContent('小明');
|
||||
expect(dialog).toHaveTextContent('插件专属设置');
|
||||
expect(dialog).toHaveTextContent('创建开发数据空间');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '从当前项目禁用开发数据服务' }));
|
||||
expect(view.onCommand).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('alertdialog')).toHaveTextContent('Project A');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认从当前项目禁用' }));
|
||||
expect(view.onCommand).toHaveBeenCalledWith({
|
||||
kind: 'disable_project', projectId: 'project-a', pluginId: 'makelore.data-service',
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches non-destructive detail commands directly and closes from the dialog control', () => {
|
||||
const view = props(officialItem);
|
||||
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '分配开发数据服务给伙伴' }));
|
||||
expect(view.onCommand).toHaveBeenCalledWith({
|
||||
kind: 'open_agent_assignment', projectId: 'project-a', pluginId: 'makelore.data-service',
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭插件详情' }));
|
||||
expect(view.onSelect).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('clears a destructive confirmation when the active project changes', () => {
|
||||
const viewA = props(officialItem);
|
||||
const { rerender } = render(<MemoryRouter><PluginsView {...viewA} /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '从当前项目禁用开发数据服务' }));
|
||||
expect(screen.getByRole('alertdialog')).toHaveTextContent('Project A');
|
||||
|
||||
const projectBItem: PluginWorkspaceItem = {
|
||||
...officialItem,
|
||||
commands: [
|
||||
{ kind: 'disable_project', projectId: 'project-b', pluginId: 'makelore.data-service' },
|
||||
],
|
||||
};
|
||||
const viewB = {
|
||||
...props(projectBItem),
|
||||
activeProjectId: 'project-b',
|
||||
activeProjectName: 'Project B',
|
||||
};
|
||||
rerender(<MemoryRouter><PluginsView {...viewB} /></MemoryRouter>);
|
||||
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
expect(viewA.onCommand).not.toHaveBeenCalled();
|
||||
expect(viewB.onCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the detail with Escape and returns focus to the originating card', async () => {
|
||||
function Harness() {
|
||||
const [selected, setSelected] = useState<PluginWorkspaceItem | null>(null);
|
||||
const view = props(selected);
|
||||
view.onSelect = vi.fn((key: PluginWorkspaceItem['key'] | null) => {
|
||||
setSelected(key === officialItem.key ? officialItem : null);
|
||||
});
|
||||
return <PluginsView {...view} />;
|
||||
}
|
||||
render(<MemoryRouter><Harness /></MemoryRouter>);
|
||||
const trigger = screen.getByRole('button', { name: '查看开发数据服务详情' });
|
||||
|
||||
fireEvent.click(trigger);
|
||||
const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
|
||||
expect(dialog).toBeVisible();
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: '开发数据服务' })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user