865 lines
32 KiB
TypeScript
865 lines
32 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { useState } from 'react';
|
|
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { Plugins, PluginsView } from '@/pages/Plugins';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { codingPluginsStore } from '@/stores/coding-plugins';
|
|
import { codingWorkspaceStore } from '@/stores/coding-workspace';
|
|
import { devicePackageStore } from '@/stores/device-packages';
|
|
import { pluginMarketplaceStore } from '@/stores/plugin-marketplace';
|
|
import type {
|
|
PluginWorkspaceItem,
|
|
PluginWorkspaceProjection,
|
|
} from '@/pages/Plugins/plugin-workspace-model';
|
|
|
|
const initialAuthState = useAuthStore.getInitialState();
|
|
const initialCodingPluginsState = codingPluginsStore.getInitialState();
|
|
const initialCodingWorkspaceState = codingWorkspaceStore.getInitialState();
|
|
const initialDevicePackageState = devicePackageStore.getInitialState();
|
|
const initialMarketplaceState = pluginMarketplaceStore.getInitialState();
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
act(() => {
|
|
useAuthStore.setState(initialAuthState, true);
|
|
codingPluginsStore.setState(initialCodingPluginsState, true);
|
|
codingWorkspaceStore.setState(initialCodingWorkspaceState, true);
|
|
devicePackageStore.setState(initialDevicePackageState, true);
|
|
pluginMarketplaceStore.setState(initialMarketplaceState, true);
|
|
});
|
|
});
|
|
|
|
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',
|
|
projectPolicyStatus: 'current',
|
|
localEnabled: null,
|
|
assignedAgentIds: ['agent-a'],
|
|
assignedAgentNames: ['小明'],
|
|
billing: 'included',
|
|
updateAvailable: false,
|
|
unavailable: false,
|
|
stale: false,
|
|
suspended: false,
|
|
retired: false,
|
|
deviceReason: null,
|
|
commands: [
|
|
{ kind: 'disable_project', 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',
|
|
projectPolicyStatus: null,
|
|
localEnabled: true,
|
|
assignedAgentIds: [],
|
|
assignedAgentNames: [],
|
|
billing: 'none',
|
|
updateAvailable: false,
|
|
unavailable: false,
|
|
stale: false,
|
|
suspended: false,
|
|
retired: false,
|
|
deviceReason: null,
|
|
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),
|
|
activeProjectId: 'project-a',
|
|
activeProjectName: 'Project A',
|
|
sourceErrors: [],
|
|
sourceLoading: [],
|
|
dataService: null,
|
|
dataServicePending: {},
|
|
activeTab: 'mine' as const,
|
|
onTabChange: 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(),
|
|
};
|
|
}
|
|
|
|
function PluginsRouteHarness() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
return (
|
|
<>
|
|
<Plugins />
|
|
<div data-testid="plugins-location">{location.pathname}{location.search}</div>
|
|
<button type="button" onClick={() => navigate('/project-config/plugins')}>打开裸插件地址</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate('/project-config/plugins?scope=illegal&source=remote&state=broken')}
|
|
>打开非法插件地址</button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function preparePluginsRouteState() {
|
|
useAuthStore.setState({ user: null });
|
|
codingWorkspaceStore.setState({
|
|
activeProjectId: null,
|
|
activeProject: null,
|
|
config: null,
|
|
loadState: 'ready',
|
|
});
|
|
codingPluginsStore.setState({ projection: null, loadState: 'ready', error: null });
|
|
devicePackageStore.setState({
|
|
index: { schemaVersion: 1, generation: 1, packages: [] },
|
|
state: 'ready',
|
|
error: null,
|
|
});
|
|
pluginMarketplaceStore.setState({
|
|
accountKey: null,
|
|
catalog: null,
|
|
catalogState: 'ready',
|
|
catalogError: null,
|
|
library: null,
|
|
libraryState: 'idle',
|
|
libraryError: null,
|
|
installations: {},
|
|
details: {},
|
|
detailState: {},
|
|
});
|
|
vi.spyOn(pluginMarketplaceStore.getState(), 'loadCatalog').mockResolvedValue(undefined);
|
|
vi.spyOn(devicePackageStore.getState(), 'load').mockResolvedValue(undefined);
|
|
vi.spyOn(codingPluginsStore.getState(), 'load').mockResolvedValue(undefined);
|
|
}
|
|
|
|
describe('PluginsView', () => {
|
|
it('renders a direct Skill-like list in the project drawer without filters or refresh', () => {
|
|
const view = props();
|
|
render(<MemoryRouter><PluginsView {...view} embedded /></MemoryRouter>);
|
|
|
|
expect(screen.getByTestId('plugins-page')).toBeVisible();
|
|
expect(screen.queryByRole('heading', { name: '插件', level: 1 })).not.toBeInTheDocument();
|
|
expect(screen.getByRole('tab', { name: /可选/ })).toHaveAttribute('aria-selected', 'false');
|
|
expect(screen.getByRole('tab', { name: /已安装/ })).toHaveAttribute('aria-selected', 'true');
|
|
fireEvent.mouseDown(screen.getByRole('tab', { name: /可选/ }), { button: 0, ctrlKey: false });
|
|
expect(view.onTabChange).toHaveBeenCalledWith('available');
|
|
expect(screen.getByTestId('plugin-list')).toHaveClass('space-y-3');
|
|
expect(screen.queryByRole('button', { name: /刷新/ })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: '查看开发数据服务详情' })).toBeVisible();
|
|
expect(screen.getByRole('button', { name: '开发数据服务:已添加到项目' })).toBeDisabled();
|
|
expect(screen.getByRole('button', { name: 'Pi Web Search:已安装' })).toBeDisabled();
|
|
expect(screen.getByText('Skill + 插件')).toBeVisible();
|
|
});
|
|
|
|
it('keeps the production URL and fallback notice aligned without exposing query controls', async () => {
|
|
preparePluginsRouteState();
|
|
render(
|
|
<MemoryRouter initialEntries={['/project-config/plugins?scope=project']}>
|
|
<PluginsRouteHarness />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
|
|
'/project-config/plugins?scope=all&source=all&state=available',
|
|
));
|
|
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
|
|
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
|
|
|
|
const activeProject = {
|
|
id: 'project-a',
|
|
name: 'Project A',
|
|
createdAt: '2026-09-03T00:00:00Z',
|
|
updatedAt: '2026-09-03T00:00:00Z',
|
|
lastOpenedAt: '2026-09-03T00:00:00Z',
|
|
};
|
|
act(() => codingWorkspaceStore.setState({ activeProjectId: activeProject.id, activeProject }));
|
|
fireEvent.click(screen.getByRole('button', { name: '打开裸插件地址' }));
|
|
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
|
|
'/project-config/plugins?scope=all&source=all&state=available',
|
|
));
|
|
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开非法插件地址' }));
|
|
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
|
|
'/project-config/plugins?scope=all&source=all&state=available',
|
|
));
|
|
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
|
|
|
|
act(() => codingWorkspaceStore.setState({ activeProjectId: null, activeProject: null }));
|
|
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
|
|
'/project-config/plugins?scope=all&source=all&state=available',
|
|
));
|
|
expect(screen.queryByLabelText('范围')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('当前没有打开的项目,已显示全部插件。')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps installed rows visible when individual sources fail to load', async () => {
|
|
const activeProject = {
|
|
id: 'project-a',
|
|
name: 'Project A',
|
|
createdAt: '2026-09-03T00:00:00Z',
|
|
updatedAt: '2026-09-03T00:00:00Z',
|
|
lastOpenedAt: '2026-09-03T00:00:00Z',
|
|
};
|
|
useAuthStore.setState({ user: null });
|
|
codingWorkspaceStore.setState({
|
|
activeProjectId: activeProject.id,
|
|
activeProject,
|
|
config: null,
|
|
loadState: 'ready',
|
|
});
|
|
codingPluginsStore.setState({
|
|
projectId: activeProject.id,
|
|
projection: {
|
|
schemaVersion: 1,
|
|
project: { localProjectId: activeProject.id, durableProjectId: 'durable-a' },
|
|
policyStatus: 'current',
|
|
unknownPluginIds: ['makelore.retained'],
|
|
items: [projectPlugin],
|
|
},
|
|
loadState: 'error',
|
|
error: 'project refresh failed',
|
|
});
|
|
devicePackageStore.setState({
|
|
index: { schemaVersion: 1, generation: 8, packages: [localItem.local!] },
|
|
state: 'error',
|
|
error: 'device refresh failed',
|
|
});
|
|
pluginMarketplaceStore.setState({
|
|
accountKey: null,
|
|
catalog: null,
|
|
catalogState: 'ready',
|
|
catalogError: null,
|
|
library: null,
|
|
libraryState: 'idle',
|
|
libraryError: null,
|
|
installations: {},
|
|
details: {},
|
|
detailState: {},
|
|
});
|
|
vi.spyOn(pluginMarketplaceStore.getState(), 'loadCatalog').mockResolvedValue(undefined);
|
|
vi.spyOn(devicePackageStore.getState(), 'load').mockResolvedValue(undefined);
|
|
vi.spyOn(codingPluginsStore.getState(), 'load').mockResolvedValue(undefined);
|
|
|
|
render(<MemoryRouter initialEntries={['/project-config/plugins?scope=project&source=all&state=mine']}><Plugins /></MemoryRouter>);
|
|
|
|
for (const name of ['开发数据服务', 'Pi Web Search', 'makelore.retained']) {
|
|
expect(await screen.findByRole('heading', { name })).toBeVisible();
|
|
}
|
|
expect(screen.getAllByRole('alert')).toHaveLength(2);
|
|
expect(screen.queryByText('缓存')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('clears legacy source filters when switching to installed so local Skills remain visible', async () => {
|
|
preparePluginsRouteState();
|
|
pluginMarketplaceStore.setState({
|
|
catalog: {
|
|
items: [{
|
|
pluginId: 'makelore.notes',
|
|
title: 'Notes',
|
|
summary: 'Write project notes',
|
|
category: 'productivity',
|
|
tags: ['notes'],
|
|
providerDisplayName: 'MakeLore',
|
|
runtimeKind: 'skill_only',
|
|
runtimeStatus: 'enabled',
|
|
acquisition: 'free',
|
|
usageBilling: 'included',
|
|
includedOperationCount: 1,
|
|
meteredOperationCount: 0,
|
|
stableVersion: '1.0.0',
|
|
betaVersion: null,
|
|
}],
|
|
nextCursor: null,
|
|
total: 1,
|
|
catalogGeneration: 1,
|
|
etag: 'catalog-1',
|
|
pricingVersionId: null,
|
|
stale: false,
|
|
fetchedAt: 1,
|
|
},
|
|
});
|
|
devicePackageStore.setState({
|
|
index: {
|
|
schemaVersion: 1,
|
|
generation: 2,
|
|
packages: [{
|
|
...localItem.local!,
|
|
packageId: 'local-writing',
|
|
displayName: 'Local Writing',
|
|
source: {
|
|
kind: 'file',
|
|
requested: '/Users/example/.codex/skills/local-writing/SKILL.md',
|
|
resolved: '/Users/example/.codex/skills/local-writing/SKILL.md',
|
|
},
|
|
kind: 'skill-only',
|
|
skillEntries: [{ id: 'local-writing', entryPath: 'SKILL.md' }],
|
|
extensionEntries: [],
|
|
confirmedExecutableCode: false,
|
|
}],
|
|
},
|
|
});
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={[
|
|
'/project-config/plugins?scope=all&source=official&state=available',
|
|
]}>
|
|
<PluginsRouteHarness />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Notes' })).toBeVisible();
|
|
fireEvent.mouseDown(screen.getByRole('tab', { name: /已安装/ }), { button: 0, ctrlKey: false });
|
|
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
|
|
'/project-config/plugins?scope=all&source=all&state=mine',
|
|
));
|
|
expect(await screen.findByRole('heading', { name: 'Local Writing' })).toBeVisible();
|
|
expect(screen.getByText('Skill')).toBeVisible();
|
|
});
|
|
|
|
it('attempts a rejected official detail load once per open and retries only after close and reopen', async () => {
|
|
preparePluginsRouteState();
|
|
pluginMarketplaceStore.setState({
|
|
catalog: {
|
|
items: [{
|
|
pluginId: 'makelore.notes',
|
|
title: 'Notes',
|
|
summary: 'Write project notes',
|
|
category: 'productivity',
|
|
tags: ['notes'],
|
|
providerDisplayName: 'MakeLore',
|
|
runtimeKind: 'skill_only',
|
|
runtimeStatus: 'enabled',
|
|
acquisition: 'free',
|
|
usageBilling: 'token_point',
|
|
includedOperationCount: 0,
|
|
meteredOperationCount: 1,
|
|
stableVersion: '2.0.0',
|
|
betaVersion: null,
|
|
}],
|
|
nextCursor: null,
|
|
total: 1,
|
|
catalogGeneration: 1,
|
|
etag: 'catalog-1',
|
|
pricingVersionId: 'pricing-1',
|
|
stale: false,
|
|
fetchedAt: 1,
|
|
},
|
|
});
|
|
|
|
let rejectFirstAttempt: (reason?: unknown) => void = () => undefined;
|
|
const firstAttempt = new Promise<void>((_resolve, reject) => {
|
|
rejectFirstAttempt = reject;
|
|
});
|
|
const loadDetail = vi.spyOn(pluginMarketplaceStore.getState(), 'loadDetail')
|
|
.mockImplementationOnce(async () => await firstAttempt)
|
|
.mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={[
|
|
'/project-config/plugins?scope=all&source=all&state=available&plugin=official%3Amakelore.notes',
|
|
]}>
|
|
<Plugins />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
await waitFor(() => expect(loadDetail).toHaveBeenCalledTimes(1));
|
|
await act(async () => {
|
|
pluginMarketplaceStore.setState((state) => ({
|
|
detailState: { ...state.detailState, 'makelore.notes': 'error' },
|
|
catalogError: 'detail offline',
|
|
}));
|
|
rejectFirstAttempt(new Error('detail offline'));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
expect(loadDetail).toHaveBeenCalledTimes(1);
|
|
expect(screen.getByRole('alert', { hidden: true })).toHaveTextContent('插件详情加载失败。 detail offline');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '关闭插件详情' }));
|
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Notes' })).not.toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole('button', { name: '查看Notes详情' }));
|
|
await waitFor(() => expect(loadDetail).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it('renders the capability-first list with details and contextual actions', () => {
|
|
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('插件与 Skill 统一管理;云端能力在这里下发,本机安装的 Skill 也会集中显示。')).toBeVisible();
|
|
expect(screen.getByText('开发数据服务')).toBeVisible();
|
|
expect(screen.getByText('Pi Web Search')).toBeVisible();
|
|
expect(screen.getByRole('button', { name: '查看开发数据服务详情' })).toBeVisible();
|
|
expect(screen.getByRole('button', { name: '开发数据服务:已添加到项目' })).toBeDisabled();
|
|
expect(screen.getAllByRole('alert')).toHaveLength(2);
|
|
expect(screen.queryByRole('button', { name: /刷新/ })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('labels an unacquired code-owned bundled plugin as supplied by MakeLore instead of not downloaded', () => {
|
|
const scaffoldItem: PluginWorkspaceItem = {
|
|
...officialItem,
|
|
key: 'official:makelore.project-scaffold',
|
|
pluginId: 'makelore.project-scaffold',
|
|
title: 'MakeLore 项目脚手架',
|
|
delivery: 'not_acquired',
|
|
projectState: 'disabled',
|
|
assignedAgentIds: [],
|
|
assignedAgentNames: [],
|
|
commands: [],
|
|
official: {
|
|
catalog: null,
|
|
detail: null,
|
|
library: null,
|
|
installation: null,
|
|
project: {
|
|
...projectPlugin,
|
|
id: 'makelore.project-scaffold',
|
|
displayName: 'MakeLore 项目脚手架',
|
|
enabled: false,
|
|
state: 'disabled',
|
|
},
|
|
},
|
|
};
|
|
const view = props(scaffoldItem);
|
|
view.projection = {
|
|
items: [scaffoldItem],
|
|
selected: scaffoldItem,
|
|
counts: { all: 1, available: 1, mine: 0, enabled: 0, update: 0, unavailable: 0 },
|
|
notices: [],
|
|
};
|
|
|
|
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
|
|
|
|
expect(screen.getByRole('button', { name: 'MakeLore 项目脚手架:随应用提供', hidden: true })).toBeDisabled();
|
|
const dialog = screen.getByRole('dialog', { name: 'MakeLore 项目脚手架' });
|
|
expect(dialog).toHaveTextContent('此插件随应用提供,添加到项目后即可使用。');
|
|
expect(dialog).not.toHaveTextContent('未下载官方包');
|
|
expect(dialog).not.toHaveTextContent('生效范围');
|
|
});
|
|
|
|
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('写入项目数据');
|
|
expect(dialog).not.toHaveTextContent('读取项目名称');
|
|
expect(dialog).not.toHaveTextContent('data-service.documents');
|
|
expect(dialog).not.toHaveTextContent('按平台包含');
|
|
expect(dialog).not.toHaveTextContent('版本 1.0.0');
|
|
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('describes local plugins by capability without exposing implementation details', () => {
|
|
const scriptSkill: PluginWorkspaceItem = {
|
|
...localItem,
|
|
key: 'local:makelore-project-scaffold',
|
|
pluginId: 'makelore-project-scaffold',
|
|
packageId: 'makelore-project-scaffold',
|
|
title: 'MakeLore 项目初始化',
|
|
local: {
|
|
...localItem.local!,
|
|
packageId: 'makelore-project-scaffold',
|
|
displayName: 'MakeLore 项目初始化',
|
|
kind: 'skill-only',
|
|
skillEntries: [{
|
|
id: 'makelore-project-scaffold',
|
|
entryPath: 'skills/makelore-project-scaffold/SKILL.md',
|
|
}],
|
|
extensionEntries: [],
|
|
},
|
|
};
|
|
|
|
render(<MemoryRouter><PluginsView {...props(scriptSkill)} /></MemoryRouter>);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: 'MakeLore 项目初始化' });
|
|
expect(dialog).toHaveTextContent('这是用户自行安装在本机的 Skill,已统一收纳在插件模块中。');
|
|
expect(dialog).not.toHaveTextContent('Skill 脚本');
|
|
expect(dialog).not.toHaveTextContent('Pi extension');
|
|
expect(dialog).not.toHaveTextContent('完整桌面权限');
|
|
});
|
|
|
|
it('shows a friendly unavailable message and keeps the applicable sign-in action', () => {
|
|
const unavailableItem: PluginWorkspaceItem = {
|
|
...officialItem,
|
|
title: 'Notes',
|
|
delivery: 'account_unknown',
|
|
projectState: 'not_applicable',
|
|
version: '1.0.0',
|
|
stale: true,
|
|
unavailable: true,
|
|
suspended: true,
|
|
retired: true,
|
|
deviceReason: '当前客户端与新版本不兼容',
|
|
commands: [{ kind: 'sign_in' }],
|
|
official: {
|
|
...officialItem.official!,
|
|
installation: {
|
|
status: 'unavailable',
|
|
pluginId: 'makelore.notes',
|
|
version: '1.0.0',
|
|
channel: 'stable',
|
|
reason: '当前客户端与新版本不兼容',
|
|
},
|
|
project: null,
|
|
},
|
|
};
|
|
const view = props(unavailableItem);
|
|
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: 'Notes' });
|
|
expect(dialog).toHaveTextContent('这项能力目前暂不可用');
|
|
expect(dialog).not.toHaveTextContent('缓存');
|
|
expect(dialog).not.toHaveTextContent('已暂停');
|
|
expect(dialog).not.toHaveTextContent('已退役');
|
|
expect(dialog).not.toHaveTextContent('1.0.0');
|
|
expect(dialog).not.toHaveTextContent('当前客户端与新版本不兼容');
|
|
fireEvent.click(screen.getByRole('button', { name: '登录后添加Notes' }));
|
|
expect(view.onCommand).toHaveBeenCalledWith({ kind: 'sign_in' });
|
|
});
|
|
|
|
it('keeps Marketplace details capability-focused without exposing operation or pricing fields', () => {
|
|
const detailItem: PluginWorkspaceItem = {
|
|
...officialItem,
|
|
title: 'Notes',
|
|
summary: '记录、整理并回顾项目笔记。',
|
|
projectState: 'not_applicable',
|
|
assignedAgentIds: [],
|
|
assignedAgentNames: [],
|
|
official: {
|
|
...officialItem.official!,
|
|
project: null,
|
|
detail: {
|
|
...officialItem.official!.detail!,
|
|
pluginId: 'makelore.notes',
|
|
title: 'Notes',
|
|
operations: [{
|
|
capabilityId: 'notes',
|
|
operation: 'write',
|
|
executionMode: 'job',
|
|
billing: {
|
|
mode: 'platform_metered',
|
|
status: null,
|
|
notice: '每次由服务端结算',
|
|
entitlementScope: 'account',
|
|
unitName: 'request',
|
|
unitSize: 2,
|
|
ratePoints: '3',
|
|
minimumChargePoints: '1',
|
|
roundingMode: 'ceil',
|
|
pricingVersion: 'pricing-7',
|
|
},
|
|
enabled: true,
|
|
}],
|
|
},
|
|
},
|
|
};
|
|
const view = {
|
|
...props(detailItem),
|
|
activeProjectId: null,
|
|
activeProjectName: null,
|
|
};
|
|
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: 'Notes' });
|
|
expect(dialog).toHaveTextContent('记录、整理并回顾项目笔记。');
|
|
expect(dialog).not.toHaveTextContent('notes');
|
|
expect(dialog).not.toHaveTextContent('write');
|
|
expect(dialog).not.toHaveTextContent('每次由服务端结算');
|
|
expect(dialog).not.toHaveTextContent('request');
|
|
expect(dialog).not.toHaveTextContent('Token Point');
|
|
expect(dialog).not.toHaveTextContent('pricing-7');
|
|
});
|
|
|
|
it.each([
|
|
['stale'],
|
|
['unavailable'],
|
|
] as const)('does not expose the %s policy implementation state in details', (projectPolicyStatus) => {
|
|
const policyItem: PluginWorkspaceItem = { ...officialItem, projectPolicyStatus };
|
|
render(<MemoryRouter><PluginsView {...props(policyItem)} /></MemoryRouter>);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
|
|
expect(dialog).not.toHaveTextContent('当前项目策略使用缓存');
|
|
expect(dialog).not.toHaveTextContent('当前项目策略不可用');
|
|
});
|
|
|
|
it('deduplicates human-facing capability descriptions and hides their technical identity', () => {
|
|
const capabilityTool = {
|
|
name: 'notes_write',
|
|
label: '整理笔记',
|
|
description: '整理并保存项目笔记。',
|
|
mutation: 'write' as const,
|
|
permissions: ['project.notes.write'],
|
|
};
|
|
const projectPolicy = {
|
|
...projectPlugin,
|
|
capabilities: [{
|
|
id: 'notes.internal',
|
|
operations: [
|
|
{
|
|
id: 'write_internal',
|
|
billing: {
|
|
mode: 'platform_metered' as const,
|
|
availability: 'available' as const,
|
|
notice: '当前项目策略价格',
|
|
ratePoints: '7',
|
|
},
|
|
tool: capabilityTool,
|
|
},
|
|
{
|
|
id: 'write_duplicate',
|
|
billing: {
|
|
mode: 'included' as const,
|
|
availability: 'available' as const,
|
|
notice: '目录包含读取',
|
|
},
|
|
tool: capabilityTool,
|
|
},
|
|
],
|
|
}],
|
|
};
|
|
const detailItem: PluginWorkspaceItem = {
|
|
...officialItem,
|
|
title: 'Notes',
|
|
official: {
|
|
...officialItem.official!,
|
|
project: projectPolicy,
|
|
detail: {
|
|
...officialItem.official!.detail!,
|
|
pluginId: 'makelore.notes',
|
|
title: 'Notes',
|
|
operations: [
|
|
{
|
|
capabilityId: 'notes',
|
|
operation: 'write',
|
|
executionMode: 'synchronous',
|
|
billing: {
|
|
mode: 'platform_metered', status: null, notice: '目录价格',
|
|
entitlementScope: null, unitName: 'catalog-request', unitSize: 1,
|
|
ratePoints: '3', minimumChargePoints: null, roundingMode: 'ceil',
|
|
pricingVersion: 'pricing-3',
|
|
},
|
|
enabled: true,
|
|
},
|
|
{
|
|
capabilityId: 'notes',
|
|
operation: 'read',
|
|
executionMode: 'synchronous',
|
|
billing: {
|
|
mode: 'included', status: null, notice: '目录包含读取',
|
|
entitlementScope: null, unitName: null, unitSize: null,
|
|
ratePoints: null, minimumChargePoints: null, roundingMode: null,
|
|
pricingVersion: 'pricing-3',
|
|
},
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
render(<MemoryRouter><PluginsView {...props(detailItem)} /></MemoryRouter>);
|
|
|
|
const dialog = screen.getByRole('dialog', { name: 'Notes' });
|
|
expect(screen.getAllByText('整理笔记')).toHaveLength(1);
|
|
expect(screen.getAllByText('整理并保存项目笔记。')).toHaveLength(1);
|
|
expect(dialog).not.toHaveTextContent('notes.internal');
|
|
expect(dialog).not.toHaveTextContent('write_internal');
|
|
expect(dialog).not.toHaveTextContent('project.notes.write');
|
|
expect(dialog).not.toHaveTextContent('当前项目策略价格');
|
|
expect(dialog).not.toHaveTextContent('目录价格');
|
|
expect(dialog).not.toHaveTextContent('Token Point');
|
|
});
|
|
|
|
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_settings', 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());
|
|
});
|
|
});
|