fix: close plugin workspace acceptance gaps

This commit is contained in:
2026-09-03 14:29:21 +08:00
parent 1b1f206dd8
commit c5020ae22c
10 changed files with 885 additions and 53 deletions

View File

@@ -44,6 +44,7 @@ describe('plugin workspace controller', () => {
{ kind: 'enable_local', packageId: 'j' },
{ kind: 'disable_local', packageId: 'k' },
{ kind: 'remove_local', packageId: 'l' },
{ kind: 'sign_in' },
{ kind: 'open_agent_assignment', projectId: 'project-a', pluginId: 'm' },
{ kind: 'open_settings', projectId: 'project-a', pluginId: 'n' },
] satisfies readonly PluginWorkspaceCommand[];
@@ -62,7 +63,7 @@ describe('plugin workspace controller', () => {
]);
expect(deps.device.setEnabled.mock.calls).toEqual([['j', true], ['k', false]]);
expect(deps.device.uninstall).toHaveBeenCalledExactlyOnceWith('l');
expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/project-config');
expect(deps.navigate.mock.calls).toEqual([['/login'], ['/project-config']]);
expect(deps.openSettings).toHaveBeenCalledExactlyOnceWith();
});

View File

@@ -111,6 +111,7 @@ function input(overrides: Partial<PluginWorkspaceInputs> = {}): PluginWorkspaceI
project,
activeProject: { id: 'project-a', name: 'Project A' },
agentNames: { 'agent-a': 'Alice' },
authenticated: true,
filters: { scope: 'all', source: 'all', state: 'all', search: '' },
...overrides,
};
@@ -331,6 +332,62 @@ describe('buildPluginWorkspaceProjection', () => {
]);
});
it('keeps an unavailable installation as a retained device version with same-channel update and cleanup', () => {
const item = buildPluginWorkspaceProjection(input({
marketplaceInstallations: {
'makelore.notes': {
status: 'unavailable',
pluginId: 'makelore.notes',
releaseId: 'release-1',
version: '1.0.0',
channel: 'stable',
reason: '当前客户端与新版本不兼容',
},
},
})).items[0]!;
expect(item).toMatchObject({
version: '1.0.0',
updateAvailable: true,
unavailable: true,
deviceReason: '当前客户端与新版本不兼容',
});
expect(item.commands.map(({ kind }) => kind)).toEqual([
'remove_from_library',
'install_beta',
'update_official',
'remove_official_device_package',
'disable_project',
]);
expect(item.commands.some(({ kind }) => kind === 'install_stable')).toBe(false);
expect(item.commands.some(({ kind }) => kind === 'open_agent_assignment')).toBe(false);
});
it('marks retained snapshots stale after refresh failure and distinguishes suspended from retired', () => {
const item = buildPluginWorkspaceProjection(input({
catalog: {
...catalog,
items: [{ ...catalog.items[0]!, runtimeStatus: 'suspended' }],
},
library: {
...library,
items: [{
...library.items[0]!,
runtimeStatus: 'suspended',
catalogStatus: 'retired',
}],
},
sourceFailures: { catalog: true, library: true },
})).items[0]!;
expect(item).toMatchObject({
stale: true,
suspended: true,
retired: true,
unavailable: true,
});
});
it('keeps an installed Beta update on the explicit Beta command', () => {
expect(commandKinds({
marketplaceInstallations: {
@@ -419,6 +476,43 @@ describe('buildPluginWorkspaceProjection', () => {
}]);
});
it('keeps a catalog-known unknown project selection as a metadata-rich disable-only retained item', () => {
const projectWithRecognizedUnknown = {
...project,
items: [],
unknownPluginIds: ['makelore.notes'],
};
const projectScope = buildPluginWorkspaceProjection(input({
project: projectWithRecognizedUnknown,
filters: { scope: 'project', source: 'all', state: 'all', search: '' },
}));
expect(projectScope.items).toHaveLength(1);
expect(projectScope.items[0]).toMatchObject({
key: 'retained:makelore.notes',
source: 'retained',
title: 'Notes',
summary: 'Write project notes',
category: 'productivity',
publisher: 'MakeLore',
commands: [{
kind: 'disable_project',
projectId: 'project-a',
pluginId: 'makelore.notes',
}],
});
const allScope = buildPluginWorkspaceProjection(input({
project: projectWithRecognizedUnknown,
filters: { scope: 'all', source: 'all', state: 'all', search: '' },
}));
expect(allScope.items.map(({ key }) => key)).toEqual([
'official:makelore.notes',
'retained:makelore.notes',
]);
expect(allScope.items[1]?.commands.map(({ kind }) => kind)).toEqual(['disable_project']);
});
it('applies scope, source, state, and search filters while keeping state counts stable', () => {
const available = {
...catalog.items[0]!,
@@ -599,4 +693,30 @@ describe('buildPluginWorkspaceProjection', () => {
projectState: 'unknown',
});
});
it('offers sign-in for a signed-out free catalog item but fails closed for an authenticated Library failure', () => {
const signedOut = buildPluginWorkspaceProjection(input({
authenticated: false,
library: null,
marketplaceInstallations: {},
project: null,
activeProject: null,
})).items[0]!;
expect(signedOut).toMatchObject({
delivery: 'account_unknown',
commands: [{ kind: 'sign_in' }],
});
const authenticatedLibraryFailure = buildPluginWorkspaceProjection(input({
authenticated: true,
library: null,
marketplaceInstallations: {},
project: null,
activeProject: null,
})).items[0]!;
expect(authenticatedLibraryFailure).toMatchObject({
delivery: 'account_unknown',
commands: [],
});
});
});

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
parsePluginWorkspaceSearch,
resolvePluginWorkspaceSearch,
serializePluginWorkspaceSearch,
} from '@/pages/Plugins/plugin-workspace-query';
@@ -22,6 +23,30 @@ describe('plugin workspace URL state', () => {
expect(parsePluginWorkspaceSearch(new URLSearchParams('scope=project'), false).scope).toBe('all');
});
it('reports a project-scope fallback while returning the exact canonical URL state', () => {
const fallback = resolvePluginWorkspaceSearch(
new URLSearchParams('scope=project&source=remote&state=broken'),
false,
);
expect(fallback).toEqual({
filters: {
scope: 'all', source: 'all', state: 'all', search: '', selectedKey: null,
},
canonicalSearch: 'scope=all&source=all&state=all',
scopeFallback: true,
});
const afterProjectAppears = resolvePluginWorkspaceSearch(new URLSearchParams(), true);
expect(afterProjectAppears).toEqual({
filters: {
scope: 'project', source: 'all', state: 'all', search: '', selectedKey: null,
},
canonicalSearch: 'scope=project&source=all&state=all',
scopeFallback: false,
});
});
it('round-trips canonical filters and source-qualified selection', () => {
const serialized = serializePluginWorkspaceSearch({
scope: 'all',

View File

@@ -1,8 +1,13 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { PluginsView } from '@/pages/Plugins';
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 {
PluginWorkspaceFilters,
PluginWorkspaceItem,
@@ -13,6 +18,23 @@ const filters: PluginWorkspaceFilters = {
scope: 'project', source: 'all', state: 'all', search: '', selectedKey: null,
};
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',
@@ -61,6 +83,9 @@ const officialItem: PluginWorkspaceItem = {
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_agent_assignment', projectId: 'project-a', pluginId: 'makelore.data-service' },
@@ -120,6 +145,9 @@ const localItem: PluginWorkspaceItem = {
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' },
@@ -171,7 +199,96 @@ function props(selected: PluginWorkspaceItem | null = null) {
};
}
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('/plugins')}>打开裸插件地址</button>
<button
type="button"
onClick={() => navigate('/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('keeps the production URL, scope control, and fallback notice aligned across in-place changes', async () => {
preparePluginsRouteState();
render(
<MemoryRouter initialEntries={['/plugins?scope=project']}>
<PluginsRouteHarness />
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=all&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('all');
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(
'/plugins?scope=project&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('project');
fireEvent.click(screen.getByRole('button', { name: '打开非法插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=project&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('project');
act(() => codingWorkspaceStore.setState({ activeProjectId: null, activeProject: null }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=all&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('all');
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
});
it('renders the unified workspace and exposes URL-backed filter changes', () => {
const view = props();
view.sourceErrors = [
@@ -226,6 +343,170 @@ describe('PluginsView', () => {
});
});
it('shows cached, suspended, retired, and retained-device reason copy with an explicit 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).toHaveTextContent('已暂停');
expect(dialog).toHaveTextContent('已退役');
expect(dialog).toHaveTextContent('官方包 1.0.0');
expect(dialog).toHaveTextContent('旧设备版本已保留:当前客户端与新版本不兼容');
fireEvent.click(screen.getByRole('button', { name: '登录后免费获取Notes' }));
expect(view.onCommand).toHaveBeenCalledWith({ kind: 'sign_in' });
});
it('shows Marketplace detail operations and server-owned pricing without an active project', () => {
const detailItem: PluginWorkspaceItem = {
...officialItem,
title: 'Notes',
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('notes');
expect(dialog).toHaveTextContent('write');
expect(dialog).toHaveTextContent('每次由服务端结算');
expect(dialog).toHaveTextContent('计费单位:request');
expect(dialog).toHaveTextContent('单位大小:2');
expect(dialog).toHaveTextContent('每单位 Token Point:3');
expect(dialog).toHaveTextContent('最低扣点:1');
expect(dialog).toHaveTextContent('价格版本:pricing-7');
});
it('deduplicates operation identity while preferring the current project policy projection', () => {
const projectPolicy = {
...projectPlugin,
capabilities: [{
id: 'notes',
operations: [{
id: 'write',
billing: {
mode: 'platform_metered' as const,
availability: 'available' as const,
notice: '当前项目策略价格',
unitName: 'project-request',
unitSize: 1,
ratePoints: '7',
minimumChargePoints: '2',
pricingVersion: 9,
roundingMode: 'ceil' as const,
},
tool: null,
}],
}],
};
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('write')).toHaveLength(1);
expect(dialog).toHaveTextContent('read');
expect(dialog).toHaveTextContent('当前项目策略价格');
expect(dialog).not.toHaveTextContent('目录价格');
expect(dialog).toHaveTextContent('每单位 Token Point:7');
expect(dialog).toHaveTextContent('价格版本:9');
expect(dialog).toHaveTextContent('目录包含读取');
});
it('dispatches non-destructive detail commands directly and closes from the dialog control', () => {
const view = props(officialItem);
render(<MemoryRouter><PluginsView {...view} /></MemoryRouter>);