From 0bfabc0df24fdd41c37af498f7a4e49bc00d31db Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 3 Sep 2026 15:07:29 +0800 Subject: [PATCH] fix(plugins): close R2 workspace gaps --- ...ugin-navigation-remediation-r2-b8e4d317.md | 125 ++++++++++++++++++ src/pages/Login/index.tsx | 22 ++- src/pages/Plugins/PluginDetails.tsx | 15 +++ src/pages/Plugins/index.tsx | 7 +- .../Plugins/plugin-workspace-controller.ts | 5 +- src/pages/Plugins/plugin-workspace-model.ts | 19 ++- tests/unit/login-page.test.tsx | 91 ++++++++++++- .../unit/plugin-workspace-controller.test.ts | 6 +- tests/unit/plugin-workspace-model.test.ts | 53 ++++++++ tests/unit/plugins-page.test.tsx | 77 +++++++++++ 10 files changed, 405 insertions(+), 15 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260903-plugin-navigation-remediation-r2-b8e4d317.md diff --git a/.project-docs/30-worklog/tasks/20260903-plugin-navigation-remediation-r2-b8e4d317.md b/.project-docs/30-worklog/tasks/20260903-plugin-navigation-remediation-r2-b8e4d317.md new file mode 100644 index 0000000..83ff643 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260903-plugin-navigation-remediation-r2-b8e4d317.md @@ -0,0 +1,125 @@ +# Task: Plugin navigation R2 remediation + +## Identity + +- Task ID: 20260903-plugin-navigation-remediation-r2-b8e4d317 +- Mode: Feature +- Branch: codex/20260903-plugin-navigation-remediation-r2-b8e4d317-plugin-navigation-remediation-r2 +- Worktree: C:\Users\7brot\.codex\worktrees\plugin-nav-remediation-r2-b8e4d317\makelore +- Base commit: c5020ae22c9bef1b5bc8190469998127842c4513 +- Owner: codex-01a0656d +- Status: Ready for Integration + +## Scope + +- Remediate the three R2 Spec findings against product base + `c5020ae22c9bef1b5bc8190469998127842c4513`. +- Preserve the exact canonical `/plugins?...` route through a successful login, + while accepting only safe internal return paths and retaining `/module-select` + as the default. +- Pass device/project refresh-failure provenance into the pure plugin workspace + projection so retained snapshots stay visible and are labelled as cached. +- Surface `CodingPluginProject.policyStatus` (`stale` or `unavailable`) beside + affected project policy/pricing; leave `current` unmarked. +- Add focused projection, page, controller, login/route regressions and run the + required unit, typecheck, lint, Vite, and relevant Electron verification. +- Produce one remediation commit and a clean READY_FOR_INTEGRATION handoff. + +## Intent And Constraints + +- Follow the authoritative unified plugin workspace Spec and the exact three + findings recorded by R2 review commit + `a698c41b39f11b4ebe15fd0f74b5fb8457ae3514`. +- Work test-first through public projection, page, controller, and router seams. +- Keep Catalog, Library, Package Store, Device Packages, project enablement, + Agent Skill assignment, runtime admission, and billing authorities unchanged. +- Do not modify stores, Electron Main, server/runtime/billing contracts, or add + a new cache/authority, compatibility layer, feature flag, or wrapper layer. +- Limit product edits to `src/pages/Plugins/**` and `src/pages/Login/index.tsx`; + a minimal existing route seam is permitted only if required by the login + regression. Corresponding unit/page/route tests and this task record are owned. +- Keep original R1 fixes covered and unchanged in semantics. +- Do not merge main, push, create a PR, deploy, publish, or install to a user + machine. Do not modify or clean any other worktree or the repository root. + +## Project Context Loaded + +- Product goal: finish ML-PLUGIN-NAV-001 R2 remediation without merging existing + backend or runtime authorities into the Renderer projection. +- Current state: base `c5020ae22c9bef1b5bc8190469998127842c4513` + contains the initial implementation and R1 remediation; R2 Standards passed + with zero findings and R2 Spec reported exactly three Medium roots. +- Relevant decisions: `/plugins` is the canonical Code-owned, initialization-safe + route; auth return uses router state; source-qualified identities and + fail-closed partial-source behavior remain intact; Renderer displays but does + not calculate or authorize project policy/billing state. +- Evidence: the authoritative Spec, R2 review record, current App/Login/Plugins + composition, device-package and coding-plugin snapshot-preservation stores, + and existing unit/Electron fixtures were inspected. +- Likely modules: `src/pages/Plugins/plugin-workspace-controller.ts`, + `src/pages/Plugins/plugin-workspace-model.ts`, `src/pages/Plugins/index.tsx`, + `src/pages/Plugins/PluginDetails.tsx`, `src/pages/Login/index.tsx`, and focused + tests under `tests/unit` (plus relevant existing Electron coverage if needed). +- Unknowns/risks: dependency availability in the fresh worktree must be checked; + the stable public route unit seam may be more appropriate than expanding the + Electron fixture for the login-return contract. +- Concurrent task assessment: the authoritative task owner exactly matches this + managed worktree, branch, and base. Same-topic implementation/remediation/review + tasks are READY_FOR_INTEGRATION; remaining planning peers declare no overlapping + ownership of the scoped Plugin/Login files or semantics. +- Concurrent Task Gate: Passed. +- Planning Gate: Passed. + +## Outcome + +- Plugin acquisition sign-in now carries the exact canonical workspace path + (including scope/source/state/search/selected-plugin query) in router state. + Login returns to that same path after successful credential and model sync. +- Login accepts only supported same-app absolute return paths with one leading + slash. Absolute external and protocol-relative targets fall back to + `/module-select`; the existing no-return default is unchanged. +- The pure workspace projection now receives bounded device/project refresh + failure provenance. Preserved local package, project-backed official, and + retained unknown rows stay visible and render as cached; project failure does + not mark catalog-only official rows. +- Project policy status is projected verbatim onto project-associated official + and retained rows. Details explain `stale` and `unavailable` beside billing, + while `current` remains unmarked. No status changes commands, admission, or + client-side pricing. +- No stores, Electron Main, server, runtime, billing authority, dependencies, + legacy routes, or unrelated product files were changed. + +## Verification + +- TDD login/controller RED: 2 expected failures (bare `/login`, fixed + `/module-select`); GREEN: 2 files, 18 tests passed. +- TDD refresh provenance RED: 2 expected failures (`stale=false` in model/page); + GREEN: 2 files, 26 tests passed. +- TDD policy status RED: 5 expected failures (missing projection and copy); + GREEN: 2 files, 32 tests passed. +- Focused plugin/login/route suite: 6 files, 58 tests passed. +- `pnpm run typecheck`: passed. +- `pnpm run lint:check`: passed with 0 errors and 5 pre-existing warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`, outside this diff. +- `pnpm test`: 225 files / 1841 tests passed / 2 skipped, plus the isolated + coding-chat pressure file 1/1 passed. +- `pnpm run build:vite`: passed. Existing Browserslist age, mixed static/dynamic + import, and chunk-size warnings remained non-failing. +- Targeted Electron E2E (`plugin-marketplace.spec.ts` and + `project-plugins.spec.ts`): final combined rerun 4/4 passed. The first combined + run was 3/4: one case timed out waiting for the test fixture to observe + `/api/auth/me` before any plugin assertion. Playwright trace showed successful + Electron launch and reload with no console/renderer crash; the isolated rerun + passed 1/1, then the full targeted rerun passed 4/4. +- Public router Login regression covers exact canonical return plus rejected + external/protocol-relative targets and unchanged default behavior. + +## Follow-ups + +- Source task will run a fresh fixed-range Standards + Spec acceptance review. +- Do not merge to main before that independent acceptance. + +## Promotion Candidates + +- None. The changes implement the already-authoritative ML-PLUGIN-NAV-001 Spec + and do not introduce a new shared architecture or product decision. diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 4df35e0..ecf820a 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { Loader2, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -15,6 +15,7 @@ const LOGIN_ERROR_MESSAGE = '登录失败,请稍后重试。'; const CAPTCHA_ERROR_MESSAGE = '图形验证码加载失败,请重试。'; const SMS_ERROR_MESSAGE = '验证码发送失败,请稍后重试。'; const PHONE_PATTERN = /^1\d{10}$/; +const DEFAULT_LOGIN_RETURN_PATH = '/module-select'; type LoginMode = 'password' | 'mobile'; @@ -101,8 +102,21 @@ function getLoginErrorMessage(error: string | null): string | null { return /[\u3400-\u9fff]/.test(message) ? message : LOGIN_ERROR_MESSAGE; } +function getSafeLoginReturnPath(state: unknown): string { + if (!state || typeof state !== 'object') return DEFAULT_LOGIN_RETURN_PATH; + const value = Reflect.get(state, 'from'); + return typeof value === 'string' + && value.startsWith('/') + && value[1] !== '/' + && value[1] !== '\\' + ? value + : DEFAULT_LOGIN_RETURN_PATH; +} + export function Login() { const navigate = useNavigate(); + const location = useLocation(); + const returnPath = getSafeLoginReturnPath(location.state); const loginWithPassword = useAuthStore((state) => state.loginWithPassword); const loginWithMobile = useAuthStore((state) => state.loginWithMobile); const loading = useAuthStore((state) => state.loading); @@ -240,9 +254,9 @@ export function Login() { useEffect(() => { if (isAuthenticated() && !submitting) { - navigate('/module-select', { replace: true }); + navigate(returnPath, { replace: true }); } - }, [isAuthenticated, navigate, submitting]); + }, [isAuthenticated, navigate, returnPath, submitting]); const finishLogin = async (login: () => Promise) => { setSubmitError(null); @@ -257,7 +271,7 @@ export function Login() { await logout(); throw syncError; } - navigate('/module-select', { replace: true }); + navigate(returnPath, { replace: true }); } catch (loginError) { setSubmitError(loginError instanceof Error ? loginError.message : String(loginError)); } finally { diff --git a/src/pages/Plugins/PluginDetails.tsx b/src/pages/Plugins/PluginDetails.tsx index c16aecd..741b7ce 100644 --- a/src/pages/Plugins/PluginDetails.tsx +++ b/src/pages/Plugins/PluginDetails.tsx @@ -128,6 +128,11 @@ const PROJECT_TEXT = { not_applicable: '不属于项目启用范围', } as const; +const PROJECT_POLICY_NOTICE = { + stale: '当前项目策略使用缓存,所示能力与价格可能不是最新状态。', + unavailable: '当前项目策略不可用,无法确认最新能力与价格。', +} as const; + function billingText(item: PluginWorkspaceItem): string { if (item.billing === 'included') return '按平台包含,不按单次插件调用扣点。'; if (item.billing === 'token_point') return '按 Token Point 实际用量计费,价格与回执以服务端为准。'; @@ -218,6 +223,10 @@ export function PluginDetails(props: PluginDetailsProps) { const detail = props.item.official?.detail ?? null; const installation = props.item.official?.installation ?? null; const operations = detailOperations(props.item); + const projectPolicyNotice = props.item.projectPolicyStatus === 'stale' + || props.item.projectPolicyStatus === 'unavailable' + ? PROJECT_POLICY_NOTICE[props.item.projectPolicyStatus] + : null; const showDataService = project?.enabled && project.settingsSurface === 'data-service'; const confirmCopy = confirmation @@ -372,6 +381,12 @@ export function PluginDetails(props: PluginDetailsProps) {

Token Point 与计费

+ {projectPolicyNotice ? ( +

+ + {projectPolicyNotice} +

+ ) : null}

{billingText(props.item)}

diff --git a/src/pages/Plugins/index.tsx b/src/pages/Plugins/index.tsx index 1e0987d..67c385f 100644 --- a/src/pages/Plugins/index.tsx +++ b/src/pages/Plugins/index.tsx @@ -415,6 +415,8 @@ export function Plugins() { sourceFailures: { catalog: catalogState === 'error', library: Boolean(accountKey) && libraryState === 'error', + device: deviceState === 'error', + project: projectLoadState === 'error', }, filters, }), [ @@ -425,11 +427,13 @@ export function Plugins() { currentProjectProjection, details, devicePackages, + deviceState, filters, installations, library, libraryState, accountKey, + projectLoadState, ]); const projection = useMemo(() => { if (!scopeFallbackNotice && !searchResolution.scopeFallback) return baseProjection; @@ -602,7 +606,8 @@ export function Plugins() { marketplace: pluginMarketplaceStore.getState(), device: devicePackageStore.getState(), project: codingPluginsStore.getState(), - navigate: (path) => navigate(path), + loginReturnPath: `/plugins?${serializePluginWorkspaceSearch(filters).toString()}`, + navigate: (path, options) => navigate(path, options), openSettings: () => document.getElementById('plugin-settings')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), })); diff --git a/src/pages/Plugins/plugin-workspace-controller.ts b/src/pages/Plugins/plugin-workspace-controller.ts index c2f7637..13d376d 100644 --- a/src/pages/Plugins/plugin-workspace-controller.ts +++ b/src/pages/Plugins/plugin-workspace-controller.ts @@ -10,7 +10,8 @@ export interface PluginWorkspaceDispatchDependencies { >; device: Pick; project: Pick; - navigate(path: string): void; + loginReturnPath: string; + navigate(path: string, options?: { state?: unknown }): void; openSettings(): void; } @@ -54,7 +55,7 @@ export async function dispatchPluginWorkspaceCommand( await dependencies.device.uninstall(command.packageId); return; case 'sign_in': - dependencies.navigate('/login'); + dependencies.navigate('/login', { state: { from: dependencies.loginReturnPath } }); return; case 'open_agent_assignment': dependencies.navigate('/project-config'); diff --git a/src/pages/Plugins/plugin-workspace-model.ts b/src/pages/Plugins/plugin-workspace-model.ts index 8a0e0d1..d28d984 100644 --- a/src/pages/Plugins/plugin-workspace-model.ts +++ b/src/pages/Plugins/plugin-workspace-model.ts @@ -88,6 +88,7 @@ export interface PluginWorkspaceItem { version: string | null; delivery: PluginWorkspaceDelivery; projectState: PluginWorkspaceProjectState; + projectPolicyStatus: CodingPluginProject['policyStatus'] | null; localEnabled: boolean | null; assignedAgentIds: readonly string[]; assignedAgentNames: readonly string[]; @@ -118,7 +119,12 @@ export interface PluginWorkspaceInputs { activeProject: { id: string; name: string } | null; agentNames: Readonly>; authenticated: boolean; - sourceFailures?: Readonly<{ catalog?: boolean; library?: boolean }>; + sourceFailures?: Readonly<{ + catalog?: boolean; + library?: boolean; + device?: boolean; + project?: boolean; + }>; filters: PluginWorkspaceFilters; } @@ -294,6 +300,7 @@ function officialItem( ? 'account_removed' : input.authenticated && input.library ? 'not_acquired' : 'account_unknown', projectState, + projectPolicyStatus: record.project ? input.project?.policyStatus ?? null : null, localEnabled: null, assignedAgentIds: agents.ids, assignedAgentNames: agents.names, @@ -309,6 +316,7 @@ function officialItem( || input.library?.stale || (record.catalog && input.sourceFailures?.catalog) || (record.library && input.sourceFailures?.library) + || (record.project && input.sourceFailures?.project) ), suspended, retired, @@ -325,7 +333,7 @@ function officialItem( }; } -function localItem(local: DevicePackageRecordV1): PluginWorkspaceItem { +function localItem(local: DevicePackageRecordV1, input: PluginWorkspaceInputs): PluginWorkspaceItem { return { key: `local:${local.packageId}`, source: 'local', @@ -339,13 +347,14 @@ function localItem(local: DevicePackageRecordV1): PluginWorkspaceItem { version: local.resolvedVersion, delivery: 'local_installed', projectState: 'not_applicable', + projectPolicyStatus: null, localEnabled: local.enabled, assignedAgentIds: [], assignedAgentNames: [], billing: 'none', updateAvailable: false, unavailable: false, - stale: false, + stale: Boolean(input.sourceFailures?.device), suspended: false, retired: false, deviceReason: null, @@ -381,6 +390,7 @@ function retainedItem( version: record?.installation?.version ?? base?.stableVersion ?? null, delivery: 'retained', projectState: 'unavailable', + projectPolicyStatus: input.project?.policyStatus ?? null, localEnabled: null, assignedAgentIds: [], assignedAgentNames: [], @@ -393,6 +403,7 @@ function retainedItem( || (record?.library && input.library?.stale) || (record?.catalog && input.sourceFailures?.catalog) || (record?.library && input.sourceFailures?.library) + || input.sourceFailures?.project ), suspended, retired, @@ -480,7 +491,7 @@ export function buildPluginWorkspaceProjection(input: PluginWorkspaceInputs): Pl }); const localItems = [...(input.devicePackages?.packages ?? [])] .sort((left, right) => left.displayName.localeCompare(right.displayName)) - .map(localItem); + .map((local) => localItem(local, input)); const retainedItems = orderedUnique(input.project?.unknownPluginIds ?? []) .filter((pluginId) => pluginId !== NATIVE_WEB_SEARCH_ID && !records.get(pluginId)?.project) .map((pluginId) => retainedItem( diff --git a/tests/unit/login-page.test.tsx b/tests/unit/login-page.test.tsx index 83a3ba2..c6ee75b 100644 --- a/tests/unit/login-page.test.tsx +++ b/tests/unit/login-page.test.tsx @@ -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 {location.pathname}{location.search}; +} + +function PluginSignInAction() { + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + {location.pathname}{location.search} + + + ); +} + +function renderLogin(initialEntries: React.ComponentProps['initialEntries'] = ['/login']) { return render( - + } /> Module Selection} /> + } /> + + , + ); +} + +function renderPluginLoginJourney(canonicalPath: string) { + return render( + + + } /> + } /> + Module Selection} /> , ); @@ -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' }); diff --git a/tests/unit/plugin-workspace-controller.test.ts b/tests/unit/plugin-workspace-controller.test.ts index e3241a7..acc0471 100644 --- a/tests/unit/plugin-workspace-controller.test.ts +++ b/tests/unit/plugin-workspace-controller.test.ts @@ -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(); }); diff --git a/tests/unit/plugin-workspace-model.test.ts b/tests/unit/plugin-workspace-model.test.ts index f4562eb..52e4bcc 100644 --- a/tests/unit/plugin-workspace-model.test.ts +++ b/tests/unit/plugin-workspace-model.test.ts @@ -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, diff --git a/tests/unit/plugins-page.test.tsx b/tests/unit/plugins-page.test.tsx index a366798..8547536 100644 --- a/tests/unit/plugins-page.test.tsx +++ b/tests/unit/plugins-page.test.tsx @@ -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(); + + 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(); + + expect(screen.getByRole('dialog', { name: '开发数据服务' })).toHaveTextContent(copy); + }); + + it('does not mark current project policy as stale or unavailable', () => { + render(); + + 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,