feat: add project plugin center
This commit is contained in:
94
tests/unit/coding-plugins-client.test.ts
Normal file
94
tests/unit/coding-plugins-client.test.ts
Normal 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'] }),
|
||||
});
|
||||
});
|
||||
});
|
||||
58
tests/unit/coding-plugins-store.test.ts
Normal file
58
tests/unit/coding-plugins-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
46
tests/unit/data-service-plugin-settings.test.tsx
Normal file
46
tests/unit/data-service-plugin-settings.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
52
tests/unit/project-plugins-page.test.tsx
Normal file
52
tests/unit/project-plugins-page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user