fix(plugins): close R2 workspace gaps
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Login } from '@/pages/Login';
|
||||
import { dispatchPluginWorkspaceCommand } from '@/pages/Plugins/plugin-workspace-controller';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
|
||||
@@ -36,12 +37,63 @@ function resetAuthStore() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderLogin() {
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="login-destination">{location.pathname}{location.search}</output>;
|
||||
}
|
||||
|
||||
function PluginSignInAction() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<output data-testid="login-destination">{location.pathname}{location.search}</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatchPluginWorkspaceCommand({ kind: 'sign_in' }, {
|
||||
marketplace: {
|
||||
acquire: async () => undefined,
|
||||
remove: async () => undefined,
|
||||
install: async () => undefined,
|
||||
installBeta: async () => undefined,
|
||||
update: async () => undefined,
|
||||
uninstall: async () => undefined,
|
||||
},
|
||||
device: {
|
||||
setEnabled: async () => undefined,
|
||||
uninstall: async () => undefined,
|
||||
},
|
||||
project: { setEnabled: async () => undefined },
|
||||
loginReturnPath: `${location.pathname}${location.search}`,
|
||||
navigate,
|
||||
openSettings: () => undefined,
|
||||
})}
|
||||
>
|
||||
登录后获取插件
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderLogin(initialEntries: React.ComponentProps<typeof MemoryRouter>['initialEntries'] = ['/login']) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/login']}>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/module-select" element={<div>Module Selection</div>} />
|
||||
<Route path="/plugins" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderPluginLoginJourney(canonicalPath: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[canonicalPath]}>
|
||||
<Routes>
|
||||
<Route path="/plugins" element={<PluginSignInAction />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/module-select" element={<div>Module Selection</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -192,6 +244,39 @@ describe('Login page', () => {
|
||||
expect(importUserModelConfig).toHaveBeenCalledWith('password-access-token');
|
||||
});
|
||||
|
||||
it('returns a signed-out plugin action to the exact canonical workspace query after login', async () => {
|
||||
const canonicalPath = '/plugins?scope=all&source=official&state=available&q=notes&plugin=official%3Amakelore.notes';
|
||||
loginWithPassword.mockImplementation(async () => {
|
||||
useAuthStore.setState({ accessToken: 'password-access-token' });
|
||||
});
|
||||
renderPluginLoginJourney(canonicalPath);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录后获取插件' }));
|
||||
fireEvent.change(await screen.findByLabelText('用户名'), { target: { value: 'zhangsan' } });
|
||||
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(await screen.findByTestId('login-destination')).toHaveTextContent(canonicalPath);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://attacker.example/plugins',
|
||||
'//attacker.example/plugins',
|
||||
])('rejects unsafe login return %s and keeps the module chooser default', async (unsafeReturn) => {
|
||||
loginWithPassword.mockImplementation(async () => {
|
||||
useAuthStore.setState({ accessToken: 'password-access-token' });
|
||||
});
|
||||
renderLogin([{ pathname: '/login', state: { from: unsafeReturn } }]);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('用户名'), { target: { value: 'zhangsan' } });
|
||||
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /我已阅读并同意/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(await screen.findByText('Module Selection')).toBeVisible();
|
||||
});
|
||||
|
||||
it('validates a Chinese mobile number, uses one-time-code autocomplete, and submits only phone and SMS code', async () => {
|
||||
loginWithMobile.mockImplementation(async () => {
|
||||
useAuthStore.setState({ accessToken: 'mobile-access-token' });
|
||||
|
||||
@@ -23,6 +23,7 @@ function dependencies() {
|
||||
project: {
|
||||
setEnabled: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
loginReturnPath: '/plugins?scope=all&source=official&state=available',
|
||||
navigate: vi.fn(),
|
||||
openSettings: vi.fn(),
|
||||
} satisfies PluginWorkspaceDispatchDependencies;
|
||||
@@ -63,7 +64,10 @@ describe('plugin workspace controller', () => {
|
||||
]);
|
||||
expect(deps.device.setEnabled.mock.calls).toEqual([['j', true], ['k', false]]);
|
||||
expect(deps.device.uninstall).toHaveBeenCalledExactlyOnceWith('l');
|
||||
expect(deps.navigate.mock.calls).toEqual([['/login'], ['/project-config']]);
|
||||
expect(deps.navigate.mock.calls).toEqual([
|
||||
['/login', { state: { from: '/plugins?scope=all&source=official&state=available' } }],
|
||||
['/project-config'],
|
||||
]);
|
||||
expect(deps.openSettings).toHaveBeenCalledExactlyOnceWith();
|
||||
});
|
||||
|
||||
|
||||
@@ -694,6 +694,59 @@ describe('buildPluginWorkspaceProjection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps device and project snapshots visible and marks only their retained rows stale after refresh errors', () => {
|
||||
const catalogOnly = {
|
||||
...catalog.items[0]!,
|
||||
pluginId: 'makelore.catalog-only',
|
||||
title: 'Catalog Only',
|
||||
};
|
||||
const result = buildPluginWorkspaceProjection(input({
|
||||
catalog: { ...catalog, items: [...catalog.items, catalogOnly], total: 2 },
|
||||
devicePackages: {
|
||||
schemaVersion: 1,
|
||||
generation: 8,
|
||||
packages: [devicePackage('local.cached', 'Cached Local', true)],
|
||||
},
|
||||
project: {
|
||||
...project,
|
||||
unknownPluginIds: ['makelore.retained'],
|
||||
},
|
||||
sourceFailures: { device: true, project: true },
|
||||
}));
|
||||
|
||||
expect(result.items.map(({ key }) => key)).toEqual([
|
||||
'official:makelore.notes',
|
||||
'official:makelore.catalog-only',
|
||||
'local:local.cached',
|
||||
'retained:makelore.retained',
|
||||
]);
|
||||
expect(result.items.find(({ key }) => key === 'official:makelore.notes')?.stale).toBe(true);
|
||||
expect(result.items.find(({ key }) => key === 'official:makelore.catalog-only')?.stale).toBe(false);
|
||||
expect(result.items.find(({ key }) => key === 'local:local.cached')?.stale).toBe(true);
|
||||
expect(result.items.find(({ key }) => key === 'retained:makelore.retained')?.stale).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['current', 'stale', 'unavailable'] as const)(
|
||||
'projects %s policy status onto project-backed and retained rows without changing actions',
|
||||
(policyStatus) => {
|
||||
const result = buildPluginWorkspaceProjection(input({
|
||||
project: {
|
||||
...project,
|
||||
policyStatus,
|
||||
unknownPluginIds: ['makelore.retained'],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.items.find(({ key }) => key === 'official:makelore.notes')).toMatchObject({
|
||||
projectPolicyStatus: policyStatus,
|
||||
});
|
||||
expect(result.items.find(({ key }) => key === 'retained:makelore.retained')).toMatchObject({
|
||||
projectPolicyStatus: policyStatus,
|
||||
commands: [{ kind: 'disable_project', projectId: 'project-a', pluginId: 'makelore.retained' }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
@@ -76,6 +76,7 @@ const officialItem: PluginWorkspaceItem = {
|
||||
version: '1.0.0',
|
||||
delivery: 'system_included',
|
||||
projectState: 'enabled',
|
||||
projectPolicyStatus: 'current',
|
||||
localEnabled: null,
|
||||
assignedAgentIds: ['agent-a'],
|
||||
assignedAgentNames: ['小明'],
|
||||
@@ -138,6 +139,7 @@ const localItem: PluginWorkspaceItem = {
|
||||
version: '1.2.3',
|
||||
delivery: 'local_installed',
|
||||
projectState: 'not_applicable',
|
||||
projectPolicyStatus: null,
|
||||
localEnabled: true,
|
||||
assignedAgentIds: [],
|
||||
assignedAgentNames: [],
|
||||
@@ -289,6 +291,63 @@ describe('PluginsView', () => {
|
||||
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps old device and project rows visible and labels each affected snapshot cached after refresh errors', async () => {
|
||||
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',
|
||||
};
|
||||
useAuthStore.setState({ user: null });
|
||||
codingWorkspaceStore.setState({
|
||||
activeProjectId: activeProject.id,
|
||||
activeProject,
|
||||
config: null,
|
||||
loadState: 'ready',
|
||||
});
|
||||
codingPluginsStore.setState({
|
||||
projectId: activeProject.id,
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
project: { localProjectId: activeProject.id, durableProjectId: 'durable-a' },
|
||||
policyStatus: 'current',
|
||||
unknownPluginIds: ['makelore.retained'],
|
||||
items: [projectPlugin],
|
||||
},
|
||||
loadState: 'error',
|
||||
error: 'project refresh failed',
|
||||
});
|
||||
devicePackageStore.setState({
|
||||
index: { schemaVersion: 1, generation: 8, packages: [localItem.local!] },
|
||||
state: 'error',
|
||||
error: 'device refresh failed',
|
||||
});
|
||||
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);
|
||||
|
||||
render(<MemoryRouter initialEntries={['/plugins?scope=project&source=all&state=all']}><Plugins /></MemoryRouter>);
|
||||
|
||||
for (const name of ['开发数据服务', 'Pi Web Search', 'makelore.retained']) {
|
||||
const heading = await screen.findByRole('heading', { name });
|
||||
expect(heading.closest('article')).toHaveTextContent('缓存');
|
||||
}
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders the unified workspace and exposes URL-backed filter changes', () => {
|
||||
const view = props();
|
||||
view.sourceErrors = [
|
||||
@@ -434,6 +493,24 @@ describe('PluginsView', () => {
|
||||
expect(dialog).toHaveTextContent('价格版本:pricing-7');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['stale', '当前项目策略使用缓存,所示能力与价格可能不是最新状态。'],
|
||||
['unavailable', '当前项目策略不可用,无法确认最新能力与价格。'],
|
||||
] as const)('shows %s project policy status beside policy-owned capability and pricing', (projectPolicyStatus, copy) => {
|
||||
const policyItem: PluginWorkspaceItem = { ...officialItem, projectPolicyStatus };
|
||||
render(<MemoryRouter><PluginsView {...props(policyItem)} /></MemoryRouter>);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '开发数据服务' })).toHaveTextContent(copy);
|
||||
});
|
||||
|
||||
it('does not mark current project policy as stale or unavailable', () => {
|
||||
render(<MemoryRouter><PluginsView {...props(officialItem)} /></MemoryRouter>);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
|
||||
expect(dialog).not.toHaveTextContent('当前项目策略使用缓存');
|
||||
expect(dialog).not.toHaveTextContent('当前项目策略不可用');
|
||||
});
|
||||
|
||||
it('deduplicates operation identity while preferring the current project policy projection', () => {
|
||||
const projectPolicy = {
|
||||
...projectPlugin,
|
||||
|
||||
Reference in New Issue
Block a user