Files
makelore/tests/unit/plugins-page.test.tsx

288 lines
11 KiB
TypeScript

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());
});
});