From 97c9ad1aad2638609168a8e1901b61b5ba671def Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 28 Aug 2026 18:41:23 +0800 Subject: [PATCH] feat: add plugin marketplace renderer --- ...60828-marketplace-mlm04-renderer-e4c7a2.md | 203 ++++++++++++ electron/api/coding-product-services.ts | 45 ++- electron/coding-plugins/project-service.ts | 6 +- src/App.tsx | 4 + src/components/layout/MainLayout.tsx | 2 + src/components/layout/Sidebar.tsx | 34 ++ src/lib/ai-modules.ts | 12 +- src/lib/coding-plugins.ts | 6 +- src/lib/plugin-marketplace.ts | 290 ++++++++++++++++++ src/pages/MyPlugins/index.tsx | 98 ++++++ src/pages/PluginMarketplace/index.tsx | 129 ++++++++ src/pages/ProjectPlugins/index.tsx | 24 +- src/stores/plugin-marketplace.ts | 253 +++++++++++++++ tests/e2e/plugin-marketplace.spec.ts | 59 ++++ tests/e2e/project-plugins.spec.ts | 6 +- tests/unit/coding-plugin-composition.test.ts | 27 ++ tests/unit/coding-plugin-routes.test.ts | 2 + tests/unit/coding-plugins-client.test.ts | 1 + tests/unit/coding-plugins-store.test.ts | 1 + tests/unit/coding-product-services.test.ts | 76 +++++ tests/unit/main-layout-module-gate.test.tsx | 9 + tests/unit/plugin-marketplace-client.test.ts | 50 +++ tests/unit/plugin-marketplace-pages.test.tsx | 128 ++++++++ tests/unit/plugin-marketplace-routes.test.ts | 16 + tests/unit/plugin-marketplace-store.test.ts | 89 ++++++ tests/unit/project-plugin-service.test.ts | 21 +- tests/unit/project-plugins-page.test.tsx | 17 +- 27 files changed, 1585 insertions(+), 23 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260828-marketplace-mlm04-renderer-e4c7a2.md create mode 100644 src/lib/plugin-marketplace.ts create mode 100644 src/pages/MyPlugins/index.tsx create mode 100644 src/pages/PluginMarketplace/index.tsx create mode 100644 src/stores/plugin-marketplace.ts create mode 100644 tests/e2e/plugin-marketplace.spec.ts create mode 100644 tests/unit/plugin-marketplace-client.test.ts create mode 100644 tests/unit/plugin-marketplace-pages.test.tsx create mode 100644 tests/unit/plugin-marketplace-store.test.ts diff --git a/.project-docs/30-worklog/tasks/20260828-marketplace-mlm04-renderer-e4c7a2.md b/.project-docs/30-worklog/tasks/20260828-marketplace-mlm04-renderer-e4c7a2.md new file mode 100644 index 0000000..42b3b0e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260828-marketplace-mlm04-renderer-e4c7a2.md @@ -0,0 +1,203 @@ +# Task: Marketplace Release A MLM-04 Renderer + +## Identity + +- Task ID: 20260828-marketplace-mlm04-renderer-e4c7a2 +- Mode: Feature +- Branch: codex/20260828-marketplace-mlm04-renderer-e4c7a2-marketplace-mlm04-renderer +- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm04-renderer-e4c7a2 +- Base commit: 8b6824a8ba08d8df98fc75af17e170bf3d8ed630 +- Owner: marketplace-mlm04-renderer +- Status: Ready for integration + +## Scope + +- Implement Marketplace Release A ticket MLM-04 from exact integrated MLM-02/03 + frontier `8b6824a8ba08d8df98fc75af17e170bf3d8ed630`. +- Own only new `src/pages/PluginMarketplace/**`, `src/pages/MyPlugins/**`, + `src/lib/plugin-marketplace.ts`, `src/stores/plugin-marketplace.ts`, joined + installation/effective-state changes in `src/pages/ProjectPlugins/**` and + `src/stores/coding-plugins.ts`, global Renderer routing/navigation seams, and + focused Renderer/API/store/component/E2E tests. +- Do not modify Main, manifest/parser, registry, Pi, Package Store, server, + Operations, Release B, or unrelated Renderer behavior. +- Coordinator ownership ruling transferred one minimal Main seam to this task: + `electron/api/coding-product-services.ts` plus its focused service test and, only + if needed for closed HTTP-shape verification, the existing Marketplace route + test. Route source, composition, and Package Store source remain out of scope. +- A second coordinator ruling within the same transferred service seam requires + `CodingPluginProjectProjection.unknownPluginIds`: stable sorted selected IDs absent + from real bundled/installed definitions. Renderer may display these as retained + unavailable configuration but must not invent package metadata or chain any action. + This ruling explicitly includes the corresponding Renderer parser + `src/lib/coding-plugins.ts` and focused composition/route/parser integration tests. +- A third narrow transfer covers `electron/coding-plugins/project-service.ts` and + its focused test only: disabling an unknown ID is allowed solely when that exact ID + already exists in the current selection. Unknown enable and unselected unknown + disable remain rejected; other IDs and legacy behavior are unchanged. + +## Intent And Constraints + +- Keep `免费获取`, `下载`/`更新`, `启用到项目`, and `分配给伙伴` visible and + independent. Every action calls only its corresponding MLM-03 Main route and + never supplies account, path, Release, admission, or other authority. +- Cover catalog/search/category/detail, Library/install/update/remove/reacquire, + system-included/free and included/token-point/mixed copy, stable status/failure + states, retained unknown/removed project IDs, and unchanged typed Data Service + settings. +- Preserve account/project A→B→A intent and per-scope mutation epochs so stale + reads or mutations cannot commit after navigation, logout, or a newer action. +- Stay inside the existing light design system. Apply the interface-quality skill + only where the owned UI truly needs it: minimum 40px targets, explicit + transition properties, balanced headings, pretty body copy, tabular dynamic + numbers, and no first-load decorative animation or new dependency. +- The canonical integrated memory predates Marketplace; the accepted design, + implementation spec, ticket graph, exact frontier, and MLM-02/03 task outcomes + are the current authority. + +## Project Context Loaded + +Task context: +- Task ID: `20260828-marketplace-mlm04-renderer-e4c7a2` +- Mode: Feature +- Branch: `codex/20260828-marketplace-mlm04-renderer-e4c7a2-marketplace-mlm04-renderer` +- Worktree: `D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm04-renderer-e4c7a2` +- Base commit: `8b6824a8ba08d8df98fc75af17e170bf3d8ed630` +- Other active local tasks: Marketplace coordinator plus completed MLM-02/03 + implementers and the prior Plugin P0 coordinator; unrelated registry owners are + outside this exact branch and ownership set. +- Overlap or semantic-conflict assessment: no unresolved conflict. MLM-02 and + MLM-03 are integrated at this exact frontier; their Main routes and closed DTOs + are read-only inputs. The coordinator and user root remain untouched. + +Read: +- mandatory `implement-spec`, `maintain-project-docs`, and + `make-interfaces-feel-better` skills, including typography, surfaces, + animations, and performance references +- repository `AGENTS.md`, project-memory startup set, architecture/domain/evidence/ + reflection/commitment/stale indexes, active and relevant peer task records +- Marketplace implementation specification sections 11–13, ticket graph MLM-04, + and accepted design Renderer/offline/failure/acceptance sections +- integrated local Marketplace route/service DTOs and existing project Plugin + Center store/page/routing/navigation seams + +Relevant understanding: +- Electron Main owns authentication, Marketplace access, package trust, installation, + account binding, Release selection, and admissions. Renderer consumes bounded + projections through fixed local Host API paths. +- Marketplace Release A separates account Library, device installation, project + selection, Agent assignment, runtime authorization, and billing. Neither server + publication nor any UI action may chain these states implicitly. +- Project plugin configuration remains schema 1 and retains unknown/removed IDs. + Data Service remains bundled, system-included, typed, and included. +- First catalog failure is unknown/error; a prior trusted catalog may be stale. + Retired differs from suspended, and bad/yanked/incompatible/signature states must + remain visible without inventing a fallback listing. +- Likely files are exactly the owned new pages/lib/store, ProjectPlugins/coding store + join, App/layout/sidebar/module routing, and focused tests/E2E. +- Unknowns: the Main public catalog/Library projection does not itself join all + project state. The initial MLM-03 local Library response also lacked device + installation state; the coordinator rejected a session-observed approximation + and authorized the narrow Main-derived joined Library projection above. + +Gate result: +- Concurrent Task Gate: Passed. Project-doc check passed; task-context ownership + exactly matches task ID, owner `marketplace-mlm04-renderer`, feature mode, + worktree, branch, and base. +- Planning Gate: Passed. Required memory/spec/design/peer scopes and exact integrated + contracts were read; no unresolved semantic or ownership conflict blocks work. + +## Implementation Plan + +1. Add failing focused tests for the safe Renderer API wrapper and account-scoped + catalog/Library/install store, including route/body exactness, no hidden chaining, + A→B→A intent, logout clearing, and mutation epochs. +2. Implement the closed Renderer DTO/client and minimal deep store over MLM-03 Main + routes, then make those tests pass without adding account/path/release/admission + authority. +3. Add failing component/navigation tests for Marketplace, My Plugins, Project + Plugins joins, the four explicit actions, state/copy/accessibility/loading/error + matrices, and retained unknown/removed IDs; implement the pages and routing. +4. Add/adjust the targeted Marketplace Electron E2E, run focused and adjacent tests, + typecheck, lint, build, and target E2E, then complete drift/task gates and create + one source commit whose sole parent is the exact base. + +## Outcome + +- Added the global Plugin Center catalog/search/category/detail experience and an + account-scoped My Plugins Library for acquire, download, update, remove, + reacquire, and uninstall. Acquisition, device installation, project enablement, + and partner assignment remain four visible independent actions. +- Added a closed Renderer client and store over fixed MLM-03 Main routes. The store + clears account state on logout/switch, rejects stale A→B→A reads, and uses + mutation epochs so an older mutation cannot overwrite a newer projection. +- Joined Main-owned Library snapshots with safe current device installation + projections. Fresh-process reads and acquire/remove return the same atomic shape; + installation projection contains only status/plugin/release/version and is + filtered to the active account Library. +- Preserved selected unknown project plugin IDs as explicit “配置保留、当前不可用” + rows without fabricated metadata or implicit actions. A selected unknown ID may + be explicitly disabled; unknown enable and unselected unknown disable remain + rejected. Existing Data Service settings and included billing behavior are + unchanged. +- Added global lazy routes/sidebar entries and kept Plugin Center/My Plugins outside + provider-initialization gating while retaining programming-module access rules. +- Covered system-included/free acquisition and included/token-point/mixed billing + copy, stale/empty/error states, retired/suspended/unavailable listings, absent or + incompatible releases, signature/package failures, and removed/uninstalled joins. +- Coordinator ownership transfers used during implementation: + 1. `electron/api/coding-product-services.ts` joined Library projection and its + focused service/route tests. + 2. `unknownPluginIds` in that service plus the corresponding Renderer parser + `src/lib/coding-plugins.ts` and composition/route/parser integration tests. + 3. `electron/coding-plugins/project-service.ts` selected-unknown disable seam and + its focused test. + No further Main, route source, composition, Package Store, registry, Pi, server, + Operations, or Release B ownership was required. + +### Interface skill impact + +#### Interaction and motion + +| Before | After | +| --- | --- | +| New Marketplace actions had no owned UI implementation | Added at least 40px hit targets (`min-h-10`) with keyboard-visible focus rings in Plugin Center, My Plugins, Project Plugins, and global navigation | +| New Marketplace surfaces had no transition contract | Added only explicit `transition-colors` or `transition-[box-shadow,background-color]`; no `transition-all`, new animation dependency, or first-load decorative animation | + +#### Typography and dynamic data + +| Before | After | +| --- | --- | +| New Marketplace heading/body copy had no wrapping behavior | Added `text-balance` to headings and `text-pretty` to explanatory/error copy so narrow layouts wrap intentionally | +| New catalog counts, versions, and point values used default numeral spacing | Added `tabular-nums` where dynamic Marketplace numbers are compared or updated | + +#### Surfaces + +| Before | After | +| --- | --- | +| New Marketplace cards and status states had no visual hierarchy | Reused existing `background`, `surface-subtle`, `brand-soft`, border, shadow, warning, and destructive tokens for catalog, detail, Library, loading, empty, and error surfaces; no redesign or new design token was introduced | + +## Verification + +- Focused/adjacent Vitest: 12 files, 73 tests passed (`plugin-marketplace` client, + store, pages; coding product service; project service; composition/routes/parsers; + Project Plugins page; MainLayout/module gate). +- `corepack pnpm run typecheck`: passed. +- `corepack pnpm run lint:check`: passed with 0 errors; 5 pre-existing warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx` remain unchanged. +- `corepack pnpm run build:vite`: passed for Renderer, Electron Main/preload, and + release utility worker. Existing dynamic-import/chunk-size warnings remain. +- Target Electron Playwright E2E: 2 passed (`plugin-marketplace.spec.ts` and + `project-plugins.spec.ts`). The first run exposed and then fixed an assertion that + included unrelated startup requests and a closed-DTO fixture missing `tool: null`. +- `corepack pnpm run test:electron:windows`: 2 files, 6 tests passed. + +## Follow-ups + +- No implementation or ownership blocker remains. Integration must preserve the + exact MLM-02/03 frontier contracts and the three coordinator-approved seam + transfers listed above. + +## Promotion Candidates + +- None recorded. diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 878b6cd..048b2bf 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -88,13 +88,18 @@ export type PublicPluginInstallation = Readonly<{ reason?: string; }>; +export type PublicPluginLibrary = Readonly<{ + library: MarketplaceLibrarySnapshot; + installations: readonly PublicPluginInstallation[]; +}>; + /** Main-owned Marketplace facade. It never exposes package roots or manifests. */ export interface CodingPluginMarketplaceService { readCatalog(input?: CatalogQuery): Promise; readDetail(pluginId: string): Promise; - readLibrary(): Promise; - acquire(pluginId: string): Promise; - remove(pluginId: string): Promise; + readLibrary(): Promise; + acquire(pluginId: string): Promise; + remove(pluginId: string): Promise; install(pluginId: string): Promise; update(pluginId: string): Promise; uninstall(pluginId: string): Promise; @@ -120,6 +125,30 @@ function publicInstallation(snapshot: InstallationSnapshot): PublicPluginInstall }; } +async function publicLibrary( + library: MarketplaceLibrarySnapshot, + packageStore: Pick, +): Promise { + const libraryPluginIds = new Set(library.items.map(({ pluginId }) => pluginId)); + const latest = new Map>[number]>(); + for (const record of await packageStore.readInstalledIndex()) { + if (!libraryPluginIds.has(record.pluginId)) continue; + const current = latest.get(record.pluginId); + if (!current || record.installedAt >= current.installedAt) latest.set(record.pluginId, record); + } + return { + library, + installations: [...latest.values()] + .sort((left, right) => left.pluginId.localeCompare(right.pluginId)) + .map((record) => ({ + status: 'installed', + pluginId: record.pluginId, + releaseId: record.releaseId, + version: record.version, + })), + }; +} + export function createCodingPluginMarketplaceService( options: CreateCodingPluginMarketplaceServiceOptions, ): CodingPluginMarketplaceService { @@ -140,16 +169,16 @@ export function createCodingPluginMarketplaceService( return { readCatalog: (input = {}) => options.marketplace.readCatalog(input), readDetail: (pluginId) => options.marketplace.readDetail(pluginId), - readLibrary: () => options.marketplace.readLibrary(), + readLibrary: async () => publicLibrary(await options.marketplace.readLibrary(), options.packageStore), acquire: async (pluginId) => { const snapshot = await options.marketplace.acquire(pluginId); await options.onChanged?.({ pluginId, kind: 'acquire' }); - return snapshot; + return publicLibrary(snapshot, options.packageStore); }, remove: async (pluginId) => { const snapshot = await options.marketplace.remove(pluginId); await options.onChanged?.({ pluginId, kind: 'remove' }); - return snapshot; + return publicLibrary(snapshot, options.packageStore); }, install: (pluginId) => install(pluginId, 'install'), update: (pluginId) => install(pluginId, 'update'), @@ -185,6 +214,7 @@ export interface CodingPluginProjectProjection { durableProjectId: string | null; }; policyStatus: PluginPolicyAvailability; + unknownPluginIds: string[]; items: Array<{ id: string; version: string; @@ -470,6 +500,9 @@ export function createCodingProjectPluginService( schemaVersion: 1, project: { localProjectId: localProject.id, durableProjectId }, policyStatus: policy.status, + unknownPluginIds: [...new Set(selection.filter((id) => ( + !pluginDefinitions.some((definition) => definition.id === id) + )))].sort(), items, }; } diff --git a/electron/coding-plugins/project-service.ts b/electron/coding-plugins/project-service.ts index 6d68979..e14d2c2 100644 --- a/electron/coding-plugins/project-service.ts +++ b/electron/coding-plugins/project-service.ts @@ -286,14 +286,14 @@ export class ProjectPluginService { ); } const knownIds = knownPluginIdSet(this.options.knownPluginIds); - if (!knownIds.has(id)) { + const current = await this.readSelection(normalizedProjectPath); + const wasEnabled = current.enabledPluginIds.includes(id); + if (!knownIds.has(id) && (enabled || !wasEnabled)) { throw new ProjectPluginServiceError( 'CODING_PLUGIN_UNKNOWN', `Unknown coding plugin: ${id}`, ); } - const current = await this.readSelection(normalizedProjectPath); - const wasEnabled = current.enabledPluginIds.includes(id); // A missing read is intentionally side-effect free. A real state // change (or the legacy projection) is the user mutation that creates // the selection file. diff --git a/src/App.tsx b/src/App.tsx index 76f61bc..59a94a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,8 @@ const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component }))); const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component }))); const ProjectPlugins = lazy(() => import('./pages/ProjectPlugins').then(({ ProjectPlugins: component }) => ({ default: component }))); +const PluginMarketplace = lazy(() => import('./pages/PluginMarketplace').then(({ PluginMarketplace: component }) => ({ default: component }))); +const MyPlugins = lazy(() => import('./pages/MyPlugins').then(({ MyPlugins: component }) => ({ default: component }))); const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component }))); const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component }))); const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component }))); @@ -455,6 +457,8 @@ function App() { > } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 0e0aa5d..b87954d 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -39,6 +39,8 @@ export function MainLayout() { const isChatWorkspace = location.pathname === '/chat'; const isInitializationSafeRoute = location.pathname === '/project-config' || location.pathname === '/project-plugins' + || location.pathname === '/plugin-marketplace' + || location.pathname === '/my-plugins' || !isProgrammingModule; const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => { diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4ae164a..3f90e63 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -13,6 +13,8 @@ import { LogOut, Plus, Puzzle, + Library, + Store, Settings as SettingsIcon, UserCircle2, } from 'lucide-react'; @@ -586,6 +588,38 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi {!sidebarCollapsed && '新建项目'} +
+ + +
+ {props.library?.stale ?

正在显示上次可信的插件库;服务恢复前,在线计量或托管能力保持不可用。

: null} + {props.state === 'loading' && !props.library ?

正在读取我的插件…

: null} + {props.state === 'error' && !props.library ?

无法读取我的插件

{props.error ?? '请稍后重试。'}

: null} + {visibleError && props.library ?

{visibleError}

: null} + {props.library && props.library.items.length === 0 ?

账号插件库还是空的

前往插件中心免费获取需要的插件。

: null} +
+ {props.library?.items.map((plugin) => { + const installed = props.installations[plugin.pluginId]; + const removed = plugin.removedAt !== null; + const suspended = plugin.runtimeStatus === 'suspended'; + const retired = plugin.catalogStatus === 'retired'; + const updateAvailable = Boolean(installed?.version && plugin.stableVersion && installed.version !== plugin.stableVersion); + const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`)); + const failure = failureText(installed?.reason); + return
+

{plugin.title}

{plugin.acquisition === 'system_included' ? 系统内置 : removed ? 已移除 : 已获取}

{plugin.summary}

稳定版 {plugin.stableVersion ?? '不可用'}

{installed?.version ?

设备版本 {installed.version}

:

尚未下载到设备

}
+
{suspended ?

运行已暂停

: null}{retired ?

{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}

: null}{failure ?

{failure}

: null}
+
+ {removed ? + : !installed ? + : updateAvailable ? + : 设备版本已是最新} + {!removed && plugin.acquisition !== 'system_included' ? : null} + {installed ? : null} + + +
+
; + })} +
+ + ); +} + +export function MyPlugins() { + const user = useAuthStore((state) => state.user); + const accountKey = user?.userId ?? user?.username ?? null; + const library = usePluginMarketplaceStore((state) => state.library); + const installations = usePluginMarketplaceStore((state) => state.installations); + const state = usePluginMarketplaceStore((value) => value.libraryState); + const error = usePluginMarketplaceStore((value) => value.libraryError); + const pending = usePluginMarketplaceStore((value) => value.pending); + useEffect(() => { + pluginMarketplaceStore.getState().activateAccount(accountKey); + if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined); + }, [accountKey]); + const safe = (operation: Promise) => operation.catch((reason) => { + toast.error(reason instanceof Error ? reason.message : String(reason)); + }); + const store = pluginMarketplaceStore.getState; + return safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />; +} diff --git a/src/pages/PluginMarketplace/index.tsx b/src/pages/PluginMarketplace/index.tsx new file mode 100644 index 0000000..f12130e --- /dev/null +++ b/src/pages/PluginMarketplace/index.tsx @@ -0,0 +1,129 @@ +import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { CheckCircle2, Download, Puzzle, Search, ShieldCheck } from 'lucide-react'; +import { toast } from 'sonner'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import type { + MarketplaceCatalogPage, + MarketplaceCatalogQuery, + MarketplaceLibrarySnapshot, + MarketplacePluginDetail, + MarketplaceUsageBilling, +} from '@/lib/plugin-marketplace'; +import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace'; +import { useAuthStore } from '@/stores/auth'; + +const USAGE_COPY: Record = { + included: '当前包含,不按单次调用扣点', + token_point: '按 Token Point 实际用量计费', + mixed: '部分能力包含,部分按 Token Point 用量计费', +}; + +export type PluginMarketplaceViewProps = { + catalog: MarketplaceCatalogPage | null; + detail: MarketplacePluginDetail | null; + library: MarketplaceLibrarySnapshot | null; + catalogState: 'idle' | 'loading' | 'ready' | 'error'; + catalogError?: string | null; + pending: Record; + onSearch(input: MarketplaceCatalogQuery): void | Promise; + onSelect(pluginId: string): void | Promise; + onAcquire(pluginId: string): void | Promise; +}; + +export function PluginMarketplaceView(props: PluginMarketplaceViewProps) { + const [query, setQuery] = useState(''); + const [category, setCategory] = useState(''); + const categories = useMemo(() => [...new Set(props.catalog?.items.map((item) => item.category) ?? [])].sort(), [props.catalog]); + const acquiredIds = useMemo(() => new Set(props.library?.items + .filter(({ removedAt }) => removedAt === null) + .map(({ pluginId }) => pluginId) ?? []), [props.library]); + const submit = (event: FormEvent) => { + event.preventDefault(); + void props.onSearch({ ...(query.trim() ? { query: query.trim() } : {}), ...(category ? { category } : {}), limit: 24 }); + }; + + return ( +
+
+
+

插件中心

+

浏览 MakeLore 运营发行的插件。免费获取只加入账号插件库,不会自动下载、启用项目或分配给伙伴。

+
+ {props.catalog ?

{props.catalog.total} 个插件

: null} +
+ +
+ + + +
+ + {props.catalog?.stale ?

正在显示上次可信的缓存目录;刷新成功前不会把本地安装包伪装成新的目录信息。

: null} + {props.catalogState === 'loading' && !props.catalog ?

正在读取插件目录…

: null} + {props.catalogState === 'error' && !props.catalog ?

插件目录暂不可用

首次读取失败,不会从本地包推测目录或计费策略。{props.catalogError ? ` ${props.catalogError}` : ''}

: null} + {props.catalog && props.catalog.items.length === 0 ?

没有匹配的插件

试试其他关键词或分类。

: null} + +
+ {props.catalog?.items.map((plugin) => { + const acquired = acquiredIds.has(plugin.pluginId); + const acquiring = Boolean(props.pending[`acquire:${plugin.pluginId}`]); + return ( +
+
+
{plugin.category}

{plugin.title}

+ {plugin.acquisition === 'system_included' ? 系统内置 : 免费获取} +
+

{plugin.summary}

+
+
提供方
{plugin.providerDisplayName}
+
稳定版
{plugin.stableVersion ?? '暂不可用'}
+
+

{USAGE_COPY[plugin.usageBilling]}

+ {plugin.runtimeStatus === 'suspended' ?

运行已暂停,暂不能获取或下载。

: null} +
+ + {plugin.acquisition === 'system_included' || acquired + ? {plugin.acquisition === 'system_included' ? '随 MakeLore 提供' : '已免费获取'} + : } +
+
+ ); + })} +
+ + {props.detail ? : null} +
+ ); +} + +export function PluginMarketplace() { + const user = useAuthStore((state) => state.user); + const accountKey = user?.userId ?? user?.username ?? null; + const catalog = usePluginMarketplaceStore((state) => state.catalog); + const catalogState = usePluginMarketplaceStore((state) => state.catalogState); + const catalogError = usePluginMarketplaceStore((state) => state.catalogError); + const library = usePluginMarketplaceStore((state) => state.library); + const details = usePluginMarketplaceStore((state) => state.details); + const pending = usePluginMarketplaceStore((state) => state.pending); + const [selectedId, setSelectedId] = useState(null); + useEffect(() => { + pluginMarketplaceStore.getState().activateAccount(accountKey); + void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined); + if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined); + }, [accountKey]); + const safe = (operation: Promise) => operation.catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)); + }); + return safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} />; +} diff --git a/src/pages/ProjectPlugins/index.tsx b/src/pages/ProjectPlugins/index.tsx index cdad1e3..5997db4 100644 --- a/src/pages/ProjectPlugins/index.tsx +++ b/src/pages/ProjectPlugins/index.tsx @@ -9,6 +9,9 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import type { CodingPluginItem, CodingPluginProject, PluginBilling } from '@/lib/coding-plugins'; import { cn } from '@/lib/utils'; import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins'; +import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace'; +import { useAuthStore } from '@/stores/auth'; +import type { MarketplaceInstallation, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import type { DataServiceInstanceState } from '../../../shared/data-service'; @@ -45,6 +48,8 @@ type ViewProps = { pending: Record; agentNames: Record; refreshError?: string | null; + marketplaceLibrary?: MarketplaceLibrarySnapshot | null; + installations?: Record; onRefresh(): void | Promise; onSetEnabled(pluginId: string, enabled: boolean): void | Promise; onConfigure(collections: string[]): void | Promise; @@ -62,6 +67,8 @@ export function ProjectPluginsView(props: ViewProps) { ? PLUGIN_SETTINGS_SURFACES[selected.settingsSurface as keyof typeof PLUGIN_SETTINGS_SURFACES] : null; const pendingToggle = selected ? Boolean(props.pending[`enabled:${props.projection.project.localProjectId}:${selected.id}`]) : false; + const libraryEntry = selected ? props.marketplaceLibrary?.items.find(({ pluginId }) => pluginId === selected.id) : null; + const installation = selected ? props.installations?.[selected.id] : null; return (
@@ -83,8 +90,10 @@ export function ProjectPluginsView(props: ViewProps) { {selected ? (

{selected.displayName}

{selected.description}

- {selected.enabled ? : } + {selected.enabled ? : }
+ {libraryEntry?.removedAt ?

账号插件库已移除;项目配置仍保留此插件 ID,重新免费获取后可恢复。

: null} + {libraryEntry?.acquisitionMode === 'user_acquired' && !libraryEntry.removedAt && !installation ?

插件已免费获取但尚未下载到本机,当前项目配置会继续保留。

: null}

发布者

MakeLore

包版本

{selected.version}

@@ -96,7 +105,7 @@ export function ProjectPluginsView(props: ViewProps) {

能力与权限

{selected.capabilities.map((capability) =>

{capability.id}

{capability.operations.map((operation) =>
{operation.id}{operation.tool ? <>{MUTATION_TEXT[operation.tool.mutation]}{operation.tool.name} : SDK}
{operation.tool ? <>

{operation.tool.description}

{operation.tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}

:

由生成的 Data Service SDK 使用,不作为 Pi 工具暴露。

}

{billingText(operation.billing)}

)}
)}
-

伙伴分配

+

伙伴分配

{selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).length ? selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).map((id) => {props.agentNames[id] ?? '未知伙伴'}) :

尚未分配给任何伙伴。

}

用量与计费

各操作的计费模式已在上方能力列表中逐项显示;平台计量操作的流水仍由权益所有者查看。

@@ -104,12 +113,15 @@ export function ProjectPluginsView(props: ViewProps) {
) :

当前没有可用插件。

}
+ {props.projection.unknownPluginIds.length ?

配置保留、当前不可用

这些插件 ID 仍保存在项目配置中,但当前账号或设备没有可验证的插件定义。不会自动获取、下载、启用或分配。

    {props.projection.unknownPluginIds.map((pluginId) =>
  • {pluginId}
  • )}
: null} { if (!open) setDisableCandidate(null); }}>禁用{disableCandidate?.displayName}?禁用后,伙伴工具和预览访问会立即停止;云端数据会保留,不会被删除。 ); } export function ProjectPlugins() { + const user = useAuthStore((state) => state.user); + const accountKey = user?.userId ?? user?.username ?? null; const activeProject = useCodingWorkspaceStore((state) => state.activeProject); const config = useCodingWorkspaceStore((state) => state.config); const projection = useCodingPluginsStore((state) => state.projection); @@ -117,8 +129,14 @@ export function ProjectPlugins() { const pending = useCodingPluginsStore((state) => state.pending); const loadState = useCodingPluginsStore((state) => state.loadState); const error = useCodingPluginsStore((state) => state.error); + const marketplaceLibrary = usePluginMarketplaceStore((state) => state.library); + const installations = usePluginMarketplaceStore((state) => state.installations); const agentNames = useMemo(() => Object.fromEntries((config?.agents ?? []).map((agent) => [agent.id, agent.name || agent.roleName])), [config]); useEffect(() => { if (activeProject) void codingPluginsStore.getState().load(activeProject.id).catch(() => undefined); }, [activeProject]); + useEffect(() => { + pluginMarketplaceStore.getState().activateAccount(accountKey); + if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined); + }, [accountKey]); if (!activeProject) return

请先选择一个项目。

; const currentProjection = projection?.project.localProjectId === activeProject.id ? projection : null; if (!currentProjection && loadState === 'loading') return

正在读取项目插件…

; @@ -126,5 +144,5 @@ export function ProjectPlugins() { const safe = (promise: Promise) => promise.catch((reason) => { toast.error(reason instanceof Error ? reason.message : String(reason)); }); - return safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={(pluginId, enabled) => safe(codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled))} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} />; + return safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={(pluginId, enabled) => safe(codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled))} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} />; } diff --git a/src/stores/plugin-marketplace.ts b/src/stores/plugin-marketplace.ts new file mode 100644 index 0000000..d5b3a7d --- /dev/null +++ b/src/stores/plugin-marketplace.ts @@ -0,0 +1,253 @@ +import { createStore, type StoreApi } from 'zustand'; +import { useStore } from 'zustand'; +import { + acquireMarketplacePlugin, + installMarketplacePlugin, + readMarketplaceCatalog, + readMarketplaceDetail, + readMarketplaceLibrary, + removeMarketplacePlugin, + uninstallMarketplacePlugin, + updateMarketplacePlugin, + type MarketplaceCatalogPage, + type MarketplaceCatalogQuery, + type MarketplaceInstallation, + type MarketplaceLibraryProjection, + type MarketplaceLibrarySnapshot, + type MarketplacePluginDetail, +} from '@/lib/plugin-marketplace'; + +type LoadState = 'idle' | 'loading' | 'ready' | 'error'; + +export type PluginMarketplaceDependencies = { + readCatalog(input?: MarketplaceCatalogQuery): Promise; + readDetail(pluginId: string): Promise; + readLibrary(): Promise; + acquire(pluginId: string): Promise; + remove(pluginId: string): Promise; + install(pluginId: string): Promise; + update(pluginId: string): Promise; + uninstall(pluginId: string): Promise; +}; + +export type PluginMarketplaceState = { + accountKey: string | null; + catalog: MarketplaceCatalogPage | null; + catalogState: LoadState; + catalogError: string | null; + catalogQuery: MarketplaceCatalogQuery; + library: MarketplaceLibrarySnapshot | null; + libraryState: LoadState; + libraryError: string | null; + installations: Record; + details: Record; + detailState: Record; + pending: Record; + activateAccount(accountKey: string | null): void; + loadCatalog(input?: MarketplaceCatalogQuery): Promise; + loadDetail(pluginId: string): Promise; + loadLibrary(): Promise; + acquire(pluginId: string): Promise; + remove(pluginId: string): Promise; + install(pluginId: string): Promise; + update(pluginId: string): Promise; + uninstall(pluginId: string): Promise; +}; + +const defaults: PluginMarketplaceDependencies = { + readCatalog: readMarketplaceCatalog, + readDetail: readMarketplaceDetail, + readLibrary: readMarketplaceLibrary, + acquire: acquireMarketplacePlugin, + remove: removeMarketplacePlugin, + install: installMarketplacePlugin, + update: updateMarketplacePlugin, + uninstall: uninstallMarketplacePlugin, +}; + +function message(error: unknown): string { + const base = error instanceof Error ? error.message : String(error); + if (!error || typeof error !== 'object') return base; + const details = 'details' in error && error.details && typeof error.details === 'object' + ? error.details as Record + : null; + const backendCode = details?.backendCode; + return typeof backendCode === 'string' ? `${backendCode}: ${base}` : base; +} + +function installationMap(items: readonly MarketplaceInstallation[]): Record { + return Object.fromEntries(items.map((item) => [item.pluginId, item])); +} + +export function createPluginMarketplaceStore( + overrides: Partial = {}, +): StoreApi { + const deps = { ...defaults, ...overrides }; + let scopeGeneration = 0; + let catalogGeneration = 0; + let libraryMutationEpoch = 0; + + return createStore((set, get) => { + const pending = async (key: string, operation: () => Promise): Promise => { + set((state) => ({ pending: { ...state.pending, [key]: true } })); + try { + return await operation(); + } finally { + set((state) => { + const next = { ...state.pending }; + delete next[key]; + return { pending: next }; + }); + } + }; + + const libraryAction = async ( + key: string, + pluginId: string, + action: (pluginId: string) => Promise, + ): Promise => pending(key, async () => { + const generation = scopeGeneration; + const accountKey = get().accountKey; + if (!accountKey) throw new Error('请先登录后管理我的插件'); + try { + const projection = await action(pluginId); + if (generation !== scopeGeneration || get().accountKey !== accountKey) return; + libraryMutationEpoch += 1; + set({ + library: projection.library, + installations: installationMap(projection.installations), + libraryState: 'ready', + libraryError: null, + }); + } catch (error) { + if (generation === scopeGeneration && get().accountKey === accountKey) { + set({ libraryError: message(error) }); + } + throw error; + } + }); + + const installAction = async ( + key: string, + pluginId: string, + action: (pluginId: string) => Promise, + ): Promise => pending(key, async () => { + const generation = scopeGeneration; + const accountKey = get().accountKey; + if (!accountKey) throw new Error('请先登录后管理设备插件'); + try { + const result = await action(pluginId); + if (generation !== scopeGeneration || get().accountKey !== accountKey) return; + libraryMutationEpoch += 1; + set((state) => { + const installations = { ...state.installations }; + if (result.status === 'removed') delete installations[pluginId]; + else installations[pluginId] = result; + return { installations, libraryError: null }; + }); + } catch (error) { + if (generation === scopeGeneration && get().accountKey === accountKey) { + set({ libraryError: message(error) }); + } + throw error; + } + }); + + return { + accountKey: null, + catalog: null, + catalogState: 'idle', + catalogError: null, + catalogQuery: {}, + library: null, + libraryState: 'idle', + libraryError: null, + installations: {}, + details: {}, + detailState: {}, + pending: {}, + activateAccount(accountKey) { + if (get().accountKey === accountKey) return; + scopeGeneration += 1; + libraryMutationEpoch = 0; + set({ + accountKey, + library: null, + libraryState: 'idle', + libraryError: null, + installations: {}, + pending: {}, + }); + }, + async loadCatalog(input = {}) { + const generation = ++catalogGeneration; + set({ catalogState: 'loading', catalogError: null, catalogQuery: input }); + try { + const catalog = await deps.readCatalog(input); + if (generation === catalogGeneration) set({ catalog, catalogState: 'ready', catalogError: null }); + } catch (error) { + if (generation === catalogGeneration) set({ catalogState: 'error', catalogError: message(error) }); + throw error; + } + }, + async loadDetail(pluginId) { + const generation = scopeGeneration; + set((state) => ({ detailState: { ...state.detailState, [pluginId]: 'loading' } })); + try { + const detail = await deps.readDetail(pluginId); + if (generation !== scopeGeneration) return; + set((state) => ({ + details: { ...state.details, [pluginId]: detail }, + detailState: { ...state.detailState, [pluginId]: 'ready' }, + })); + } catch (error) { + if (generation === scopeGeneration) { + set((state) => ({ + detailState: { ...state.detailState, [pluginId]: 'error' }, + catalogError: message(error), + })); + } + throw error; + } + }, + async loadLibrary() { + const generation = scopeGeneration; + const accountKey = get().accountKey; + const mutationEpoch = libraryMutationEpoch; + if (!accountKey) { + set({ library: null, installations: {}, libraryState: 'idle', libraryError: null }); + return; + } + set({ libraryState: 'loading', libraryError: null }); + try { + const projection = await deps.readLibrary(); + if (generation === scopeGeneration && get().accountKey === accountKey + && mutationEpoch === libraryMutationEpoch) { + set({ + library: projection.library, + installations: installationMap(projection.installations), + libraryState: 'ready', + libraryError: null, + }); + } + } catch (error) { + if (generation === scopeGeneration && get().accountKey === accountKey) { + set({ libraryState: 'error', libraryError: message(error) }); + } + throw error; + } + }, + acquire: (pluginId) => libraryAction(`acquire:${pluginId}`, pluginId, deps.acquire), + remove: (pluginId) => libraryAction(`remove:${pluginId}`, pluginId, deps.remove), + install: (pluginId) => installAction(`install:${pluginId}`, pluginId, deps.install), + update: (pluginId) => installAction(`update:${pluginId}`, pluginId, deps.update), + uninstall: (pluginId) => installAction(`uninstall:${pluginId}`, pluginId, deps.uninstall), + }; + }); +} + +export const pluginMarketplaceStore = createPluginMarketplaceStore(); + +export function usePluginMarketplaceStore(selector: (state: PluginMarketplaceState) => T): T { + return useStore(pluginMarketplaceStore, selector); +} diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts new file mode 100644 index 0000000..27b13eb --- /dev/null +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -0,0 +1,59 @@ +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +test.describe('Plugin Marketplace', () => { + test('opens the global catalog, searches, and reads detail through fixed Main routes', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await page.getByTestId('ai-module-option-programming').click(); + await app.evaluate(({ ipcMain }) => { + const requests: string[] = []; + (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E = { requests }; + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => { + const requestPath = request.path ?? ''; + requests.push(requestPath); + const item = { + pluginId: 'makelore.notes', title: '灵感笔记', summary: '整理项目灵感。', category: '效率', tags: ['笔记'], + providerDisplayName: 'MakeLore', runtimeKind: 'skill_only', runtimeStatus: 'enabled', acquisition: 'free', + usageBilling: 'included', includedOperationCount: 1, meteredOperationCount: 0, stableVersion: '1.0.0', betaVersion: null, + }; + if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) { + return { ok: true, data: { status: 200, ok: true, json: { + items: [item], nextCursor: null, total: 1, catalogGeneration: 1, + etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1, + } } }; + } + if (requestPath === '/api/coding/plugin-marketplace/plugins/makelore.notes') { + return { ok: true, data: { status: 200, ok: true, json: { + ...item, descriptionMarkdown: '详细介绍', permissions: ['读取项目名称'], operations: [], + stableRelease: { releaseId: 'release-1', pluginId: 'makelore.notes', version: '1.0.0' }, betaRelease: null, + etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1, + } } }; + } + return { ok: false, error: { message: `Unexpected Host API request: ${requestPath}` } }; + }); + }); + + await page.getByTestId('sidebar-nav-plugin-marketplace').click(); + await expect(page.getByTestId('plugin-marketplace-page')).toBeVisible(); + await expect(page.getByRole('heading', { name: '灵感笔记' })).toBeVisible(); + await page.getByRole('searchbox', { name: '搜索插件' }).fill('笔记'); + await page.getByRole('button', { name: '搜索', exact: true }).click(); + await page.getByRole('button', { name: '查看详情' }).click(); + await expect(page.getByRole('heading', { name: '灵感笔记' }).last()).toBeVisible(); + await expect(page.getByText('详细介绍')).toBeVisible(); + + const requests = await app.evaluate(() => ( + (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? [] + )); + expect(requests).toContain('/api/coding/plugin-marketplace/catalog?limit=24'); + expect(requests).toContain('/api/coding/plugin-marketplace/catalog?query=%E7%AC%94%E8%AE%B0&limit=24'); + expect(requests).toContain('/api/coding/plugin-marketplace/plugins/makelore.notes'); + const marketplaceRequests = requests.filter((requestPath) => requestPath.startsWith('/api/coding/plugin-marketplace/')); + expect(JSON.stringify(marketplaceRequests)).not.toMatch(/account|admission|release_id|path=/i); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/project-plugins.spec.ts b/tests/e2e/project-plugins.spec.ts index c78b3b7..1f8d233 100644 --- a/tests/e2e/project-plugins.spec.ts +++ b/tests/e2e/project-plugins.spec.ts @@ -36,11 +36,13 @@ test.describe('Project Plugin Center', () => { schemaVersion: 1, project: { localProjectId: state.localProjectId, durableProjectId: '11111111-1111-4111-8111-111111111111' }, policyStatus: 'current', + unknownPluginIds: [], items: [{ id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '为当前项目提供数据。', + contractVersion: 1, requiresBackend: true, enabled: state.enabled, state: state.enabled ? 'configuration_required' : 'disabled', backend: { status: 'unconfigured' }, skills: [{ id: 'data-service', assignedAgentIds: [] }], - capabilities: [{ id: 'data-service.control', operations: [{ id: 'inspect', billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' } }] }], + capabilities: [{ id: 'data-service.control', operations: [{ id: 'inspect', billing: { mode: 'included', availability: 'available', notice: 'Fixed quotas apply' }, tool: null }] }], settingsSurface: 'data-service', }], } } }; @@ -58,7 +60,7 @@ test.describe('Project Plugin Center', () => { expect(localProjectId).toBeTruthy(); await expect(page.getByText(localProjectId as string)).toHaveCount(0); await expect(page.getByText('11111111-1111-4111-8111-111111111111')).toHaveCount(0); - await page.getByRole('button', { name: '启用开发数据服务' }).click(); + await page.getByRole('button', { name: '启用到项目开发数据服务' }).click(); await expect(page.getByRole('article').getByText('待配置')).toBeVisible(); await expect(page.getByRole('button', { name: '创建开发数据空间' })).toBeDisabled(); const dataServiceCalls = await app.evaluate(() => ( diff --git a/tests/unit/coding-plugin-composition.test.ts b/tests/unit/coding-plugin-composition.test.ts index a22f7a4..51f779e 100644 --- a/tests/unit/coding-plugin-composition.test.ts +++ b/tests/unit/coding-plugin-composition.test.ts @@ -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); diff --git a/tests/unit/coding-plugin-routes.test.ts b/tests/unit/coding-plugin-routes.test.ts index 5752a84..96ccd55 100644 --- a/tests/unit/coding-plugin-routes.test.ts +++ b/tests/unit/coding-plugin-routes.test.ts @@ -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; diff --git a/tests/unit/coding-plugins-client.test.ts b/tests/unit/coding-plugins-client.test.ts index 931c825..8bec85b 100644 --- a/tests/unit/coding-plugins-client.test.ts +++ b/tests/unit/coding-plugins-client.test.ts @@ -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', diff --git a/tests/unit/coding-plugins-store.test.ts b/tests/unit/coding-plugins-store.test.ts index aeaa9a0..ac2bcb1 100644 --- a/tests/unit/coding-plugins-store.test.ts +++ b/tests/unit/coding-plugins-store.test.ts @@ -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, diff --git a/tests/unit/coding-product-services.test.ts b/tests/unit/coding-product-services.test.ts index 03ae275..fc3a972 100644 --- a/tests/unit/coding-product-services.test.ts +++ b/tests/unit/coding-product-services.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/main-layout-module-gate.test.tsx b/tests/unit/main-layout-module-gate.test.tsx index 6aedef6..0c01618 100644 --- a/tests/unit/main-layout-module-gate.test.tsx +++ b/tests/unit/main-layout-module-gate.test.tsx @@ -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'); diff --git a/tests/unit/plugin-marketplace-client.test.ts b/tests/unit/plugin-marketplace-client.test.ts new file mode 100644 index 0000000..2d2735a --- /dev/null +++ b/tests/unit/plugin-marketplace-client.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx new file mode 100644 index 0000000..c085cc1 --- /dev/null +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -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(); + + 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(); + expect(screen.getByRole('alert')).toHaveTextContent('插件目录暂不可用'); + expect(screen.queryByRole('article')).not.toBeInTheDocument(); + + rerender(); + 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(); + + 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(); + 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(); + expect(screen.getByRole('alert')).toHaveTextContent('签名或包校验失败'); + rerender(); + expect(screen.getByRole('alert')).toHaveTextContent('已撤回、尚未就绪或与当前 MakeLore 不兼容'); + }); +}); diff --git a/tests/unit/plugin-marketplace-routes.test.ts b/tests/unit/plugin-marketplace-routes.test.ts index 5168395..12cd423 100644 --- a/tests/unit/plugin-marketplace-routes.test.ts +++ b/tests/unit/plugin-marketplace-routes.test.ts @@ -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' }); diff --git a/tests/unit/plugin-marketplace-store.test.ts b/tests/unit/plugin-marketplace-store.test.ts new file mode 100644 index 0000000..9370249 --- /dev/null +++ b/tests/unit/plugin-marketplace-store.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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(), deferred(), deferred()]; + 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(); + 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(); + }); +}); diff --git a/tests/unit/project-plugin-service.test.ts b/tests/unit/project-plugin-service.test.ts index 6143266..9a40164 100644 --- a/tests/unit/project-plugin-service.test.ts +++ b/tests/unit/project-plugin-service.test.ts @@ -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' }); + }); }); diff --git a/tests/unit/project-plugins-page.test.tsx b/tests/unit/project-plugins-page.test.tsx index 2d5acb9..d6ebeff 100644 --- a/tests/unit/project-plugins-page.test.tsx +++ b/tests/unit/project-plugins-page.test.tsx @@ -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(); 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(); + 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); }); });