feat: add project plugin center

This commit is contained in:
2026-08-27 18:36:45 +08:00
parent b6d9e6156f
commit cb1fd2629c
15 changed files with 982 additions and 1 deletions

View File

@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest';
import {
configureDataService,
getCodingPlugins,
parseCodingPluginProject,
setCodingPluginEnabled,
} from '@/lib/coding-plugins';
const PROJECT_ID = 'local-project';
function projection() {
return {
schemaVersion: 1,
project: { localProjectId: PROJECT_ID, durableProjectId: null },
policyStatus: 'current',
items: [{
id: 'makelore.data-service',
version: '1.0.0',
displayName: '开发数据服务',
description: '项目数据',
enabled: false,
state: 'disabled',
backend: { status: 'unconfigured' },
skills: [{ id: 'data-service', assignedAgentIds: [] }],
capabilities: [{
id: 'data-service.control',
operations: [{
id: 'inspect',
billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' },
}],
}],
settingsSurface: 'data-service',
}],
};
}
describe('coding plugins Renderer client', () => {
it('strictly parses the bounded Main projection', () => {
expect(parseCodingPluginProject(projection())).toEqual(projection());
expect(() => parseCodingPluginProject({ ...projection(), owner: 'secret' })).toThrow();
expect(() => parseCodingPluginProject({ ...projection(), items: [{ ...projection().items[0], state: 'installed' }] })).toThrow();
});
it('uses the local project handle exactly once and enable does not configure', async () => {
const fetcher = vi.fn()
.mockResolvedValueOnce(projection())
.mockResolvedValueOnce({ ...projection(), items: [{ ...projection().items[0], enabled: true, state: 'configuration_required' }] });
await getCodingPlugins(PROJECT_ID, fetcher);
await setCodingPluginEnabled(PROJECT_ID, 'makelore.data-service', true, fetcher);
expect(fetcher).toHaveBeenNthCalledWith(1, '/api/coding/plugins?projectId=local-project');
expect(fetcher).toHaveBeenNthCalledWith(2, '/api/coding/plugins/makelore.data-service', {
method: 'PUT',
body: JSON.stringify({ projectId: PROJECT_ID, enabled: true }),
});
expect(fetcher.mock.calls.flat().join(' ')).not.toContain('/api/works/data-service/project');
});
it('configures Data Service only through its existing typed route', async () => {
const response = {
success: true,
status: 200,
code: null,
error: null,
retryable: false,
data: {
instance_id: 'instance-1',
project_id: '11111111-1111-4111-8111-111111111111',
collections: [],
usage: { document_count: 0, total_bytes: 0 },
limits: {
max_collections: 20,
max_documents: 1000,
max_total_bytes: 20971520,
max_document_bytes: 65536,
list_default_limit: 50,
list_max_limit: 100,
list_max_data_bytes: 1048576,
mutations_per_minute: 120,
},
created_at: '2026-08-27T00:00:00Z',
updated_at: '2026-08-27T00:00:00Z',
},
};
const fetcher = vi.fn().mockResolvedValue(response);
await expect(configureDataService(['todos'], fetcher)).resolves.toEqual(response);
expect(fetcher).toHaveBeenCalledWith('/api/works/data-service/project', {
method: 'PUT',
body: JSON.stringify({ collections: ['todos'] }),
});
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import { createCodingPluginsStore } from '@/stores/coding-plugins';
function projection(enabled = false, projectId = 'local-project') {
return {
schemaVersion: 1 as const,
project: { localProjectId: projectId, durableProjectId: null },
policyStatus: 'current' as const,
items: [{
id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '项目数据',
enabled, state: enabled ? 'configuration_required' as const : 'disabled' as const,
backend: { status: 'unconfigured' as const }, skills: [{ id: 'data-service', assignedAgentIds: [] }],
capabilities: [], settingsSurface: 'data-service',
}],
};
}
describe('coding plugins store', () => {
it('coalesces duplicate loads and mutations while retaining the last projection on failure', async () => {
let resolveLoad!: (value: ReturnType<typeof projection>) => void;
const list = vi.fn(() => new Promise<ReturnType<typeof projection>>((resolve) => { resolveLoad = resolve; }));
const setEnabled = vi.fn().mockResolvedValue(projection(true));
const store = createCodingPluginsStore({ list, setEnabled });
const first = store.getState().load('local-project');
const second = store.getState().load('local-project');
resolveLoad(projection());
await Promise.all([first, second]);
expect(list).toHaveBeenCalledOnce();
await Promise.all([
store.getState().setEnabled('local-project', 'makelore.data-service', true),
store.getState().setEnabled('local-project', 'makelore.data-service', true),
]);
expect(setEnabled).toHaveBeenCalledOnce();
expect(store.getState().projection?.items[0].enabled).toBe(true);
list.mockRejectedValueOnce(new Error('offline'));
await expect(store.getState().load('other-project')).rejects.toThrow('offline');
expect(store.getState().projection?.project.localProjectId).toBe('local-project');
expect(store.getState().loadState).toBe('error');
});
it('clears project-scoped Data Service data when another project loads', async () => {
const store = createCodingPluginsStore({ list: vi.fn().mockResolvedValue(projection(false, 'other-project')) });
store.setState({
projectId: 'local-project',
dataService: {
instance_id: 'instance-1', project_id: 'cloud-project', collections: [],
usage: { document_count: 0, total_bytes: 0 },
limits: { max_collections: 20, max_documents: 1000, max_total_bytes: 20971520, max_document_bytes: 65536, list_default_limit: 50, list_max_limit: 100, list_max_data_bytes: 1048576, mutations_per_minute: 120 },
created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z',
},
});
await store.getState().load('other-project');
expect(store.getState().dataService).toBeNull();
});
});

View File

@@ -0,0 +1,46 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DataServicePluginSettings } from '@/components/plugins/data-service-settings';
const ready = {
instance_id: 'instance-1', project_id: 'cloud-project',
collections: [{ name: 'todos', document_count: 3, total_bytes: 2048, created_at: '2026-08-27T00:00:00Z' }],
usage: { document_count: 3, total_bytes: 2048 },
limits: { max_collections: 20, max_documents: 1000, max_total_bytes: 20971520, max_document_bytes: 65536, list_default_limit: 50, list_max_limit: 100, list_max_data_bytes: 1048576, mutations_per_minute: 120 },
created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z',
};
describe('Data Service plugin settings', () => {
it('requires explicit collection input before configuration and prevents duplicate submit', () => {
const onConfigure = vi.fn();
render(<DataServicePluginSettings state="configuration_required" projectName="Demo" data={null} pending={{}} onConfigure={onConfigure} onReset={vi.fn()} onRemoveCollection={vi.fn()} onRemoveProject={vi.fn()} />);
fireEvent.change(screen.getByLabelText('初始 collection'), { target: { value: 'todos, settings' } });
fireEvent.click(screen.getByRole('button', { name: '创建开发数据空间' }));
expect(onConfigure).toHaveBeenCalledWith(['todos', 'settings']);
});
it('shows fixed usage and limits with tabular numerals and typed destructive confirmation', () => {
const onRemoveCollection = vi.fn();
render(<DataServicePluginSettings state="ready" projectName="Demo" data={ready} pending={{}} onConfigure={vi.fn()} onReset={vi.fn()} onRemoveCollection={onRemoveCollection} onRemoveProject={vi.fn()} />);
expect(screen.getByText('3 / 1,000')).toHaveClass('tabular-nums');
expect(screen.getByText('2 KB / 20 MB')).toHaveClass('tabular-nums');
fireEvent.click(screen.getByRole('button', { name: '移除 collection todos' }));
expect(screen.getByRole('dialog')).toHaveTextContent('todos');
const input = screen.getByLabelText('输入确认目标');
fireEvent.change(input, { target: { value: 'wrong' } });
expect(screen.getByRole('button', { name: '确认移除' })).toBeDisabled();
fireEvent.change(input, { target: { value: 'todos' } });
fireEvent.click(screen.getByRole('button', { name: '确认移除' }));
expect(onRemoveCollection).toHaveBeenCalledWith('todos');
});
it('retains last usage structure in degraded state and disables destructive actions', () => {
render(<DataServicePluginSettings state="degraded" projectName="Demo" data={ready} pending={{}} onConfigure={vi.fn()} onReset={vi.fn()} onRemoveCollection={vi.fn()} onRemoveProject={vi.fn()} />);
expect(screen.getByRole('status')).toHaveTextContent('保留页面结构和上次用量');
expect(screen.getByText('3 / 1,000')).toBeVisible();
expect(screen.getByRole('button', { name: '移除 collection todos' })).toBeDisabled();
expect(screen.getByRole('button', { name: '重置数据' })).toBeDisabled();
});
});

View File

@@ -105,6 +105,13 @@ describe('MainLayout module isolation', () => {
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
});
it('allows the project Plugin Center to resolve identity before initialization', () => {
renderLayout('/project-plugins');
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
});
it('keeps the programming workspace padding compensation separate from painting', () => {
renderLayout('/chat');

View File

@@ -0,0 +1,52 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { ProjectPluginsView } from '@/pages/ProjectPlugins';
import type { CodingPluginProject } from '@/lib/coding-plugins';
function projection(): CodingPluginProject {
return {
schemaVersion: 1,
project: { localProjectId: 'local-only-id', durableProjectId: '11111111-1111-4111-8111-111111111111' },
policyStatus: 'current',
items: [{
id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '为当前项目提供 JSON 数据。',
enabled: false, state: 'disabled', backend: { status: 'unconfigured' },
skills: [{ id: 'data-service', assignedAgentIds: ['agent-1'] }],
capabilities: [{ id: 'data-service.control', operations: [{ id: 'inspect', billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' } }] }],
settingsSurface: 'data-service',
}],
};
}
describe('Project Plugin Center', () => {
it('uses available/enabled language, text states, exact included copy, and never renders project IDs', () => {
const onSetEnabled = vi.fn();
render(<MemoryRouter><ProjectPluginsView projectName="Demo" projection={projection()} dataService={null} pending={{}} agentNames={{ 'agent-1': '小明' }} onRefresh={vi.fn()} onSetEnabled={onSetEnabled} onConfigure={vi.fn()} onReset={vi.fn()} onRemoveCollection={vi.fn()} onRemoveProject={vi.fn()} /></MemoryRouter>);
expect(screen.getByRole('heading', { name: '项目插件' })).toHaveClass('text-balance');
expect(screen.getAllByText('未启用').length).toBeGreaterThan(0);
expect(screen.getByText('当前包含,不按单次调用扣点')).toBeVisible();
expect(screen.getByText('小明')).toBeVisible();
expect(screen.getByText('data-service.control')).toBeVisible();
expect(screen.queryByText('local-only-id')).not.toBeInTheDocument();
expect(screen.queryByText('11111111-1111-4111-8111-111111111111')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '启用开发数据服务' }));
expect(onSetEnabled).toHaveBeenCalledWith('makelore.data-service', true);
});
it('keeps the last project structure visible when refresh is degraded', () => {
render(<MemoryRouter><ProjectPluginsView projectName="Demo" projection={projection()} dataService={null} pending={{}} agentNames={{}} refreshError="network unavailable" onRefresh={vi.fn()} onSetEnabled={vi.fn()} onConfigure={vi.fn()} onReset={vi.fn()} onRemoveCollection={vi.fn()} onRemoveProject={vi.fn()} /></MemoryRouter>);
expect(screen.getByRole('status')).toHaveTextContent('刷新失败');
expect(screen.getByRole('article')).toBeVisible();
});
it('explains unknown billing and keeps invocation unavailable', () => {
const value = projection();
value.items[0].state = 'unavailable';
value.items[0].capabilities[0].operations[0].billing = { mode: 'platform_metered', availability: 'unavailable', notice: 'pricing unavailable' };
render(<MemoryRouter><ProjectPluginsView projectName="Demo" projection={value} dataService={null} pending={{}} agentNames={{}} onRefresh={vi.fn()} onSetEnabled={vi.fn()} onConfigure={vi.fn()} onReset={vi.fn()} onRemoveCollection={vi.fn()} onRemoveProject={vi.fn()} /></MemoryRouter>);
expect(screen.getByText('计费策略暂不可用,相关调用已停用。')).toBeVisible();
expect(screen.getByRole('button', { name: '启用开发数据服务' })).toBeDisabled();
});
});