feat: add plugin marketplace renderer

This commit is contained in:
2026-08-28 18:41:23 +08:00
parent 8b6824a8ba
commit 97c9ad1aad
27 changed files with 1585 additions and 23 deletions

View File

@@ -83,6 +83,7 @@ describe('coding plugin bounded product service', () => {
durableProjectId: '11111111-1111-4111-8111-111111111111',
},
policyStatus: 'current',
unknownPluginIds: [],
items: [expect.objectContaining({
id: DATA_SERVICE_PLUGIN_DEFINITION.id,
enabled: true,
@@ -112,6 +113,32 @@ describe('coding plugin bounded product service', () => {
expect(JSON.stringify(result)).not.toMatch(/secret upstream body|projectPath|entitlement_scope/u);
});
it('projects retained selected IDs without inventing package metadata', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-unknown-'));
roots.push(root);
await createCodingProjectMetadata(root, { now: '2026-08-28T00:00:00.000Z' });
const service = createCodingProjectPluginService({
projects: { getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }) },
projectPlugins: {
getEnabledPluginIds: vi.fn().mockResolvedValue([
'makelore.removed', DATA_SERVICE_PLUGIN_DEFINITION.id, 'makelore.removed',
]),
setEnabled: vi.fn(),
},
policyClient: {
refresh: vi.fn().mockResolvedValue(undefined),
getState: () => ({ status: 'unavailable' as const, revision: 0, lastVerifiedAt: null, catalog: null }),
},
adapters: [],
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
});
const result = await service.list('local-a');
expect(result.unknownPluginIds).toEqual(['makelore.removed']);
expect(result.items).toHaveLength(1);
expect(result.items).not.toContainEqual(expect.objectContaining({ id: 'makelore.removed' }));
});
it('projects the exact three capabilities and fourteen mixed-policy operations', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-policy-join-'));
roots.push(root);

View File

@@ -51,6 +51,7 @@ describe('coding plugin Host routes', () => {
schemaVersion: 1 as const,
project: { localProjectId: 'local-a', durableProjectId: 'durable-a' },
policyStatus: 'current' as const,
unknownPluginIds: ['makelore.removed'],
items: [],
};
const list = vi.fn().mockResolvedValue(projection);
@@ -68,6 +69,7 @@ describe('coding plugin Host routes', () => {
schemaVersion: 1,
project: { localProjectId: 'local-a', durableProjectId: null },
policyStatus: 'unavailable',
unknownPluginIds: [],
items: [],
});
const ctx = { codingProducts: { plugins: { setEnabled } } } as unknown as HostApiContext;

View File

@@ -13,6 +13,7 @@ function projection() {
schemaVersion: 1,
project: { localProjectId: PROJECT_ID, durableProjectId: null },
policyStatus: 'current',
unknownPluginIds: [],
items: [{
id: 'makelore.data-service',
version: '1.0.0',

View File

@@ -12,6 +12,7 @@ function projection(enabled = false, projectId = 'local-project') {
schemaVersion: 1 as const,
project: { localProjectId: projectId, durableProjectId: null },
policyStatus: 'current' as const,
unknownPluginIds: [],
items: [{
id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '项目数据',
enabled, state: enabled ? 'configuration_required' as const : 'disabled' as const,

View File

@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import type { AgentBrowserModule } from '../../electron/agent-browser';
import {
CodingProductHostError,
createCodingPluginMarketplaceService,
createCodingProductHost,
type CodingProductHostOptions,
} from '../../electron/api/coding-product-services';
@@ -294,3 +295,78 @@ describe('PI-105 product Host composition', () => {
});
});
});
describe('Marketplace public Library projection', () => {
const snapshot = {
items: [{
pluginId: 'makelore.notes', title: 'Notes', summary: 'Notes', category: 'tools',
acquisition: 'free' as const, acquisitionMode: 'user_acquired' as const,
catalogStatus: 'active' as const, runtimeStatus: 'enabled' as const,
acquiredAt: '2026-08-28T00:00:00Z', removedAt: null,
stableVersion: '2.0.0', betaVersion: null,
}],
total: 1, stale: false, fetchedAt: 1,
};
const records = [
{
pluginId: 'makelore.notes', releaseId: 'release-old', version: '1.0.0',
packageSchemaVersion: 2, contractVersion: 1, runtimeKind: 'skill_only' as const,
sha256: 'a'.repeat(64), sizeBytes: 10, installedAt: '2026-08-27T00:00:00Z',
},
{
pluginId: 'makelore.notes', releaseId: 'release-new', version: '1.5.0',
packageSchemaVersion: 2, contractVersion: 1, runtimeKind: 'skill_only' as const,
sha256: 'b'.repeat(64), sizeBytes: 11, installedAt: '2026-08-28T00:00:00Z',
},
{
pluginId: 'makelore.other-account', releaseId: 'foreign-release', version: '9.0.0',
packageSchemaVersion: 2, contractVersion: 1, runtimeKind: 'skill_only' as const,
sha256: 'c'.repeat(64), sizeBytes: 12, installedAt: '2026-08-28T01:00:00Z',
},
];
it('rebuilds fresh-process installation state and exposes only the latest safe release', async () => {
const marketplace = {
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot),
acquire: vi.fn(), remove: vi.fn(),
};
const packageStore = {
readInstalledIndex: vi.fn().mockResolvedValue(records),
resolveAndInstall: vi.fn(), removeUnused: vi.fn(),
};
const service = createCodingPluginMarketplaceService({
marketplace: marketplace as never, packageStore: packageStore as never, clientVersion: '2.0.0',
});
const result = await service.readLibrary();
expect(result).toEqual({
library: snapshot,
installations: [{
status: 'installed', pluginId: 'makelore.notes',
releaseId: 'release-new', version: '1.5.0',
}],
});
expect(JSON.stringify(result)).not.toMatch(/sha256|sizeBytes|installedAt|packageRoot|definition|account|admission|token/i);
});
it.each(['acquire', 'remove'] as const)('returns an authoritative joined snapshot after %s', async (action) => {
const marketplace = {
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(),
acquire: vi.fn().mockResolvedValue(snapshot), remove: vi.fn().mockResolvedValue(snapshot),
};
const packageStore = {
readInstalledIndex: vi.fn().mockResolvedValue(records.slice(1, 2)),
resolveAndInstall: vi.fn(), removeUnused: vi.fn(),
};
const service = createCodingPluginMarketplaceService({
marketplace: marketplace as never, packageStore: packageStore as never, clientVersion: '2.0.0',
});
await expect(service[action]('makelore.notes')).resolves.toEqual({
library: snapshot,
installations: [{ status: 'installed', pluginId: 'makelore.notes', releaseId: 'release-new', version: '1.5.0' }],
});
expect(marketplace[action]).toHaveBeenCalledWith('makelore.notes');
expect(packageStore.readInstalledIndex).toHaveBeenCalledOnce();
});
});

View File

@@ -112,6 +112,15 @@ describe('MainLayout module isolation', () => {
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
});
it.each(['/plugin-marketplace', '/my-plugins'])(
'keeps global plugin route %s outside the local project initialization gate',
(path) => {
renderLayout(path);
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,50 @@
import { describe, expect, it, vi } from 'vitest';
import {
acquireMarketplacePlugin,
installMarketplacePlugin,
readMarketplaceCatalog,
removeMarketplacePlugin,
updateMarketplacePlugin,
} from '@/lib/plugin-marketplace';
const catalog = {
items: [], nextCursor: null, total: 0, catalogGeneration: 1,
etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1,
};
const library = { library: { items: [], total: 0, stale: false, fetchedAt: 1 }, installations: [] };
const installation = { status: 'installed', pluginId: 'makelore.notes', releaseId: 'release-1', version: '1.0.0' };
describe('Renderer plugin Marketplace client', () => {
it('uses only the bounded catalog query contract', async () => {
const fetcher = vi.fn().mockResolvedValue(catalog);
await expect(readMarketplaceCatalog({ query: 'notes', category: 'productivity', featured: true, limit: 24 }, fetcher)).resolves.toEqual(catalog);
expect(fetcher).toHaveBeenCalledWith('/api/coding/plugin-marketplace/catalog?query=notes&category=productivity&featured=true&limit=24');
});
it('keeps acquire, install, update, and remove as four independent requests', async () => {
const fetcher = vi.fn()
.mockResolvedValueOnce(library)
.mockResolvedValueOnce(installation)
.mockResolvedValueOnce(installation)
.mockResolvedValueOnce(library);
await acquireMarketplacePlugin('makelore.notes', fetcher);
await installMarketplacePlugin('makelore.notes', fetcher);
await updateMarketplacePlugin('makelore.notes', fetcher);
await removeMarketplacePlugin('makelore.notes', fetcher);
expect(fetcher.mock.calls).toEqual([
['/api/coding/plugin-marketplace/library/makelore.notes', { method: 'PUT', body: '{}' }],
['/api/coding/plugin-marketplace/install/makelore.notes', { method: 'POST', body: '{}' }],
['/api/coding/plugin-marketplace/update/makelore.notes', { method: 'POST', body: '{}' }],
['/api/coding/plugin-marketplace/library/makelore.notes', { method: 'DELETE', body: '{}' }],
]);
expect(JSON.stringify(fetcher.mock.calls)).not.toMatch(/account|admission|releaseId|path/i);
});
it('rejects invalid plugin identifiers before calling Main', async () => {
const fetcher = vi.fn();
await expect(acquireMarketplacePlugin('../escape', fetcher)).rejects.toThrow('plugin id');
expect(fetcher).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,128 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { PluginMarketplaceView } from '@/pages/PluginMarketplace';
import { MyPluginsView } from '@/pages/MyPlugins';
import type {
MarketplaceCatalogPage,
MarketplaceLibrarySnapshot,
MarketplacePluginDetail,
} from '@/lib/plugin-marketplace';
const item = {
pluginId: 'makelore.notes', title: '灵感笔记', summary: '整理项目灵感与任务。',
category: '效率', tags: ['笔记'], providerDisplayName: 'MakeLore',
runtimeKind: 'skill_only' as const, runtimeStatus: 'enabled' as const,
acquisition: 'free' as const, usageBilling: 'mixed' as const,
includedOperationCount: 2, meteredOperationCount: 1,
stableVersion: '2.0.0', betaVersion: null,
};
const catalog: MarketplaceCatalogPage = {
items: [item], nextCursor: null, total: 1, catalogGeneration: 2,
etag: '"plugins-2-tp-1"', pricingVersionId: '1', stale: false, fetchedAt: 1,
};
const detail: MarketplacePluginDetail = {
...item, descriptionMarkdown: '详细介绍', permissions: ['读取项目名称'], operations: [],
stableRelease: { releaseId: 'release-2', version: '2.0.0' }, betaRelease: null,
etag: '"plugins-2-tp-1"', pricingVersionId: '1', stale: false, fetchedAt: 1,
};
describe('Plugin Marketplace pages', () => {
it('supports search/detail and keeps free acquisition separate', () => {
const onSearch = vi.fn();
const onAcquire = vi.fn();
render(<MemoryRouter><PluginMarketplaceView
catalog={catalog} detail={detail} library={null} catalogState="ready" pending={{}}
onSearch={onSearch} onSelect={vi.fn()} onAcquire={onAcquire}
/></MemoryRouter>);
expect(screen.getByRole('heading', { name: '插件中心' })).toHaveClass('text-balance');
expect(screen.getByText('部分能力包含,部分按 Token Point 用量计费')).toBeVisible();
expect(screen.getByText('1')).toHaveClass('tabular-nums');
fireEvent.change(screen.getByRole('searchbox', { name: '搜索插件' }), { target: { value: '笔记' } });
fireEvent.submit(screen.getByRole('search'));
expect(onSearch).toHaveBeenCalledWith(expect.objectContaining({ query: '笔记' }));
const acquire = screen.getByRole('button', { name: '免费获取灵感笔记' });
expect(acquire).toHaveClass('min-h-10');
fireEvent.click(acquire);
expect(onAcquire).toHaveBeenCalledWith('makelore.notes');
});
it('renders the catalog first-failure and stale states without invented cards', () => {
const { rerender } = render(<MemoryRouter><PluginMarketplaceView
catalog={null} detail={null} library={null} catalogState="error" catalogError="offline"
pending={{}} onSearch={vi.fn()} onSelect={vi.fn()} onAcquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('插件目录暂不可用');
expect(screen.queryByRole('article')).not.toBeInTheDocument();
rerender(<MemoryRouter><PluginMarketplaceView
catalog={{ ...catalog, stale: true }} detail={null} library={null} catalogState="ready"
pending={{}} onSearch={vi.fn()} onSelect={vi.fn()} onAcquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByRole('status')).toHaveTextContent('缓存目录');
});
});
describe('My Plugins', () => {
const library: MarketplaceLibrarySnapshot = {
total: 4, stale: false, fetchedAt: 1,
items: [
{ pluginId: 'makelore.notes', title: '灵感笔记', summary: 'Notes', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '2.0.0', betaVersion: null },
{ pluginId: 'makelore.removed', title: '旧插件', summary: 'Removed', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-27T00:00:00Z', removedAt: '2026-08-28T00:00:00Z', stableVersion: '1.0.0', betaVersion: null },
{ pluginId: 'makelore.retired', title: '已退役插件', summary: 'Retired', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'retired', runtimeStatus: 'enabled', acquiredAt: '2026-08-26T00:00:00Z', removedAt: '2026-08-28T00:00:00Z', stableVersion: '1.0.0', betaVersion: null },
{ pluginId: 'makelore.system', title: '开发数据服务', summary: 'Data', category: '系统', acquisition: 'system_included', acquisitionMode: 'system_included', catalogStatus: 'active', runtimeStatus: 'suspended', acquiredAt: null, removedAt: null, stableVersion: '1.0.0', betaVersion: null },
],
};
it('shows update/remove/reacquire and project/partner actions without chaining them', () => {
const onUpdate = vi.fn();
const onRemove = vi.fn();
const onReacquire = vi.fn();
render(<MemoryRouter><MyPluginsView
library={library} installations={{ 'makelore.notes': { status: 'installed', pluginId: 'makelore.notes', version: '1.5.0', releaseId: 'release-1' } }}
state="ready" pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={onUpdate}
onUninstall={vi.fn()} onRemove={onRemove} onReacquire={onReacquire}
/></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: '更新灵感笔记' }));
expect(onUpdate).toHaveBeenCalledWith('makelore.notes');
expect(onRemove).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '移除灵感笔记' }));
expect(onRemove).toHaveBeenCalledWith('makelore.notes');
fireEvent.click(screen.getByRole('button', { name: '重新免费获取旧插件' }));
expect(onReacquire).toHaveBeenCalledWith('makelore.removed');
expect(screen.getAllByRole('link', { name: '启用到项目' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: '分配给伙伴' }).length).toBeGreaterThan(0);
expect(screen.getByText('运行已暂停')).toBeVisible();
expect(screen.getByText('已退役,移除后不可重新获取')).toBeVisible();
});
it('names signature, yanked/not-ready, and incompatible failures without hiding the old install', () => {
render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }}
installations={{ 'makelore.notes': { status: 'unavailable', pluginId: 'makelore.notes', version: '1.5.0', reason: 'plugin_signature_invalid plugin_release_yanked plugin_incompatible_client' } }}
state="ready" pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={vi.fn()}
onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByText(/签名校验失败/)).toBeVisible();
expect(screen.getByText(/版本已撤回或未就绪/)).toBeVisible();
expect(screen.getByText(/当前 MakeLore 版本不兼容/)).toBeVisible();
expect(screen.getByText('设备版本 1.5.0')).toBeVisible();
});
it('honestly groups stable Main failure codes without claiming hidden authority', () => {
const { rerender } = render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }} installations={{}} state="ready"
error="plugin_artifact_invalid: Plugin artifact is invalid" pending={{}} onRefresh={vi.fn()}
onInstall={vi.fn()} onUpdate={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('签名或包校验失败');
rerender(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }} installations={{}} state="ready"
error="plugin_release_not_ready: Plugin Release is not ready" pending={{}} onRefresh={vi.fn()}
onInstall={vi.fn()} onUpdate={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('已撤回、尚未就绪或与当前 MakeLore 不兼容');
});
});

View File

@@ -46,6 +46,22 @@ async function invoke(ctx: HostApiContext, method: string, target: string, body?
}
describe('Main-owned plugin Marketplace routes', () => {
it('returns the joined Library/install projection without device or account authority', async () => {
const projection = {
library: { items: [], total: 0, stale: false, fetchedAt: 1 },
installations: [{ status: 'installed', pluginId: 'notes', releaseId: 'release-1', version: '1.0.0' }],
};
const readLibrary = vi.fn().mockResolvedValue(projection);
const ctx = { codingProducts: { pluginMarketplace: {
readLibrary, readCatalog: vi.fn(), readDetail: vi.fn(), acquire: vi.fn(), remove: vi.fn(),
install: vi.fn(), update: vi.fn(), uninstall: vi.fn(),
} } } as unknown as HostApiContext;
const result = await invoke(ctx, 'GET', '/api/coding/plugin-marketplace/library');
expect(result).toMatchObject({ handled: true, status: 200, payload: projection });
expect(JSON.stringify(result.payload)).not.toMatch(/path|hash|token|account|admission|definition/i);
});
it('forwards only bounded catalog query fields and detail IDs', async () => {
const catalog = vi.fn().mockResolvedValue({ items: [], total: 0 });
const detail = vi.fn().mockResolvedValue({ pluginId: 'notes' });

View File

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import { createPluginMarketplaceStore } from '@/stores/plugin-marketplace';
import type { MarketplaceLibraryProjection, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function library(title: string): MarketplaceLibrarySnapshot {
return {
total: 1, stale: false, fetchedAt: 1,
items: [{
pluginId: 'makelore.notes', title, summary: 'Notes', category: 'tools',
acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active',
runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null,
stableVersion: '1.0.0', betaVersion: null,
}],
};
}
const projection = (title: string): MarketplaceLibraryProjection => ({
library: library(title), installations: [],
});
describe('plugin Marketplace store', () => {
it('prevents stale A → B → A account loads from committing', async () => {
const requests = [deferred<MarketplaceLibraryProjection>(), deferred<MarketplaceLibraryProjection>(), deferred<MarketplaceLibraryProjection>()];
const readLibrary = vi.fn(() => requests[readLibrary.mock.calls.length - 1].promise);
const store = createPluginMarketplaceStore({ readLibrary });
store.getState().activateAccount('account-a');
const a1 = store.getState().loadLibrary();
store.getState().activateAccount('account-b');
const b = store.getState().loadLibrary();
store.getState().activateAccount('account-a');
const a2 = store.getState().loadLibrary();
requests[2].resolve(projection('A newest'));
await a2;
requests[1].resolve(projection('B stale'));
requests[0].resolve(projection('A oldest'));
await Promise.all([a1, b]);
expect(store.getState().accountKey).toBe('account-a');
expect(store.getState().library?.items[0].title).toBe('A newest');
});
it('clears account projections immediately on logout', async () => {
const store = createPluginMarketplaceStore({ readLibrary: vi.fn().mockResolvedValue(projection('A')) });
store.getState().activateAccount('account-a');
await store.getState().loadLibrary();
store.getState().activateAccount(null);
expect(store.getState()).toMatchObject({ accountKey: null, library: null, installations: {} });
});
it('does not let a load started before a mutation overwrite the mutation result', async () => {
const stale = deferred<MarketplaceLibraryProjection>();
const store = createPluginMarketplaceStore({
readLibrary: vi.fn(() => stale.promise),
acquire: vi.fn().mockResolvedValue(projection('Acquired')),
});
store.getState().activateAccount('account-a');
const load = store.getState().loadLibrary();
await store.getState().acquire('makelore.notes');
stale.resolve(projection('Old'));
await load;
expect(store.getState().library?.items[0].title).toBe('Acquired');
});
it('calls only the selected state mutation', async () => {
const acquire = vi.fn().mockResolvedValue(projection('Acquired'));
const install = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'makelore.notes', version: '1.0.0' });
const setProjectEnabled = vi.fn();
const store = createPluginMarketplaceStore({ acquire, install });
store.getState().activateAccount('account-a');
await store.getState().acquire('makelore.notes');
expect(acquire).toHaveBeenCalledOnce();
expect(install).not.toHaveBeenCalled();
expect(setProjectEnabled).not.toHaveBeenCalled();
await store.getState().install('makelore.notes');
expect(install).toHaveBeenCalledOnce();
expect(setProjectEnabled).not.toHaveBeenCalled();
});
});

View File

@@ -156,19 +156,32 @@ describe('ProjectPluginService', () => {
await expect(service.readSelection(root)).resolves.toMatchObject({ enabledPluginIds: [] });
});
it('rejects unknown plugin mutations but retains unknown IDs already in the file', async () => {
it('allows only explicit removal of a selected unknown ID and preserves every other ID', async () => {
const root = await project();
await writeFile(selectionPath(root), JSON.stringify({
schemaVersion: 1,
enabledPluginIds: ['future.plugin'],
enabledPluginIds: ['future.plugin', 'another.future', 'makelore.data-service'],
updatedAt: '2026-08-27T04:00:00.000Z',
}), 'utf8');
const service = new ProjectPluginService();
await expect(service.enable(root, 'future.plugin')).rejects.toMatchObject({
code: 'CODING_PLUGIN_UNKNOWN',
});
await expect(service.enable(root, 'makelore.data-service')).resolves.toMatchObject({
enabledPluginIds: ['future.plugin', 'makelore.data-service'],
await expect(service.disable(root, 'future.plugin')).resolves.toMatchObject({
enabledPluginIds: ['another.future', 'makelore.data-service'],
});
const saved = JSON.parse(await readFile(selectionPath(root), 'utf8')) as { enabledPluginIds: string[] };
expect(saved).toMatchObject({
enabledPluginIds: ['another.future', 'makelore.data-service'],
});
});
it('rejects unselected unknown disable without writing a selection file', async () => {
const root = await project();
const service = new ProjectPluginService();
await expect(service.disable(root, 'future.plugin')).rejects.toMatchObject({
code: 'CODING_PLUGIN_UNKNOWN',
});
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
});
});

View File

@@ -9,6 +9,7 @@ function projection(): CodingPluginProject {
schemaVersion: 1,
project: { localProjectId: 'local-only-id', durableProjectId: '11111111-1111-4111-8111-111111111111' },
policyStatus: 'current',
unknownPluginIds: [],
items: [{
id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '为当前项目提供 JSON 数据。',
contractVersion: 1, requiresBackend: true,
@@ -60,7 +61,7 @@ describe('Project Plugin Center', () => {
expect(screen.getByText('计费策略暂不可用,相关调用已停用。')).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: '启用开发数据服务' }));
fireEvent.click(screen.getByRole('button', { name: '启用到项目开发数据服务' }));
expect(onSetEnabled).toHaveBeenCalledWith('makelore.data-service', true);
});
@@ -77,6 +78,18 @@ describe('Project Plugin Center', () => {
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();
expect(screen.getByRole('button', { name: '启用到项目开发数据服务' })).toBeDisabled();
});
it('shows retained unknown IDs without invented metadata and allows only explicit disable', () => {
const value = projection();
value.unknownPluginIds = ['makelore.removed'];
const onSetEnabled = vi.fn();
render(<MemoryRouter><ProjectPluginsView projectName="Demo" projection={value} dataService={null} pending={{}} agentNames={{}} onRefresh={vi.fn()} onSetEnabled={onSetEnabled} onConfigure={vi.fn()} onReset={vi.fn()} onRemoveCollection={vi.fn()} onRemoveProject={vi.fn()} /></MemoryRouter>);
expect(screen.getByText('配置保留、当前不可用')).toBeVisible();
expect(screen.getByText('makelore.removed')).toBeVisible();
expect(screen.queryByText(/makelore.removed.*v\d/u)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '从项目禁用保留插件 makelore.removed' }));
expect(onSetEnabled).toHaveBeenCalledWith('makelore.removed', false);
});
});