feat: add plugin marketplace renderer
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
50
tests/unit/plugin-marketplace-client.test.ts
Normal file
50
tests/unit/plugin-marketplace-client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
128
tests/unit/plugin-marketplace-pages.test.tsx
Normal file
128
tests/unit/plugin-marketplace-pages.test.tsx
Normal 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 不兼容');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
89
tests/unit/plugin-marketplace-store.test.ts
Normal file
89
tests/unit/plugin-marketplace-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user