fix(plugins): close R2 workspace gaps
This commit is contained in:
1 parent
c5020ae22c
commit
0bfabc0df2
10 files changed
+405
-15
No files matched your search
@@ -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.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
|
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 { Loader2, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -15,6 +15,7 @@ const LOGIN_ERROR_MESSAGE = '登录失败,请稍后重试。';
|
|||||||
const CAPTCHA_ERROR_MESSAGE = '图形验证码加载失败,请重试。';
|
const CAPTCHA_ERROR_MESSAGE = '图形验证码加载失败,请重试。';
|
||||||
const SMS_ERROR_MESSAGE = '验证码发送失败,请稍后重试。';
|
const SMS_ERROR_MESSAGE = '验证码发送失败,请稍后重试。';
|
||||||
const PHONE_PATTERN = /^1\d{10}$/;
|
const PHONE_PATTERN = /^1\d{10}$/;
|
||||||
|
const DEFAULT_LOGIN_RETURN_PATH = '/module-select';
|
||||||
|
|
||||||
type LoginMode = 'password' | 'mobile';
|
type LoginMode = 'password' | 'mobile';
|
||||||
|
|
||||||
@@ -101,8 +102,21 @@ function getLoginErrorMessage(error: string | null): string | null {
|
|||||||
return /[\u3400-\u9fff]/.test(message) ? message : LOGIN_ERROR_MESSAGE;
|
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() {
|
export function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const returnPath = getSafeLoginReturnPath(location.state);
|
||||||
const loginWithPassword = useAuthStore((state) => state.loginWithPassword);
|
const loginWithPassword = useAuthStore((state) => state.loginWithPassword);
|
||||||
const loginWithMobile = useAuthStore((state) => state.loginWithMobile);
|
const loginWithMobile = useAuthStore((state) => state.loginWithMobile);
|
||||||
const loading = useAuthStore((state) => state.loading);
|
const loading = useAuthStore((state) => state.loading);
|
||||||
@@ -240,9 +254,9 @@ export function Login() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated() && !submitting) {
|
if (isAuthenticated() && !submitting) {
|
||||||
navigate('/module-select', { replace: true });
|
navigate(returnPath, { replace: true });
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, navigate, submitting]);
|
}, [isAuthenticated, navigate, returnPath, submitting]);
|
||||||
|
|
||||||
const finishLogin = async (login: () => Promise<void>) => {
|
const finishLogin = async (login: () => Promise<void>) => {
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
@@ -257,7 +271,7 @@ export function Login() {
|
|||||||
await logout();
|
await logout();
|
||||||
throw syncError;
|
throw syncError;
|
||||||
}
|
}
|
||||||
navigate('/module-select', { replace: true });
|
navigate(returnPath, { replace: true });
|
||||||
} catch (loginError) {
|
} catch (loginError) {
|
||||||
setSubmitError(loginError instanceof Error ? loginError.message : String(loginError));
|
setSubmitError(loginError instanceof Error ? loginError.message : String(loginError));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ const PROJECT_TEXT = {
|
|||||||
not_applicable: '不属于项目启用范围',
|
not_applicable: '不属于项目启用范围',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const PROJECT_POLICY_NOTICE = {
|
||||||
|
stale: '当前项目策略使用缓存,所示能力与价格可能不是最新状态。',
|
||||||
|
unavailable: '当前项目策略不可用,无法确认最新能力与价格。',
|
||||||
|
} as const;
|
||||||
|
|
||||||
function billingText(item: PluginWorkspaceItem): string {
|
function billingText(item: PluginWorkspaceItem): string {
|
||||||
if (item.billing === 'included') return '按平台包含,不按单次插件调用扣点。';
|
if (item.billing === 'included') return '按平台包含,不按单次插件调用扣点。';
|
||||||
if (item.billing === 'token_point') return '按 Token Point 实际用量计费,价格与回执以服务端为准。';
|
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 detail = props.item.official?.detail ?? null;
|
||||||
const installation = props.item.official?.installation ?? null;
|
const installation = props.item.official?.installation ?? null;
|
||||||
const operations = detailOperations(props.item);
|
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
|
const showDataService = project?.enabled
|
||||||
&& project.settingsSurface === 'data-service';
|
&& project.settingsSurface === 'data-service';
|
||||||
const confirmCopy = confirmation
|
const confirmCopy = confirmation
|
||||||
@@ -372,6 +381,12 @@ export function PluginDetails(props: PluginDetailsProps) {
|
|||||||
|
|
||||||
<section aria-labelledby="plugin-billing-heading">
|
<section aria-labelledby="plugin-billing-heading">
|
||||||
<h3 id="plugin-billing-heading" className="text-lg font-semibold">Token Point 与计费</h3>
|
<h3 id="plugin-billing-heading" className="text-lg font-semibold">Token Point 与计费</h3>
|
||||||
|
{projectPolicyNotice ? (
|
||||||
|
<p role="status" className="mt-3 flex gap-2 rounded-xl bg-warning/10 p-3 text-pretty text-sm text-warning">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>{projectPolicyNotice}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<p className="mt-2 text-pretty text-sm text-muted-foreground">{billingText(props.item)}</p>
|
<p className="mt-2 text-pretty text-sm text-muted-foreground">{billingText(props.item)}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -415,6 +415,8 @@ export function Plugins() {
|
|||||||
sourceFailures: {
|
sourceFailures: {
|
||||||
catalog: catalogState === 'error',
|
catalog: catalogState === 'error',
|
||||||
library: Boolean(accountKey) && libraryState === 'error',
|
library: Boolean(accountKey) && libraryState === 'error',
|
||||||
|
device: deviceState === 'error',
|
||||||
|
project: projectLoadState === 'error',
|
||||||
},
|
},
|
||||||
filters,
|
filters,
|
||||||
}), [
|
}), [
|
||||||
@@ -425,11 +427,13 @@ export function Plugins() {
|
|||||||
currentProjectProjection,
|
currentProjectProjection,
|
||||||
details,
|
details,
|
||||||
devicePackages,
|
devicePackages,
|
||||||
|
deviceState,
|
||||||
filters,
|
filters,
|
||||||
installations,
|
installations,
|
||||||
library,
|
library,
|
||||||
libraryState,
|
libraryState,
|
||||||
accountKey,
|
accountKey,
|
||||||
|
projectLoadState,
|
||||||
]);
|
]);
|
||||||
const projection = useMemo<PluginWorkspaceProjection>(() => {
|
const projection = useMemo<PluginWorkspaceProjection>(() => {
|
||||||
if (!scopeFallbackNotice && !searchResolution.scopeFallback) return baseProjection;
|
if (!scopeFallbackNotice && !searchResolution.scopeFallback) return baseProjection;
|
||||||
@@ -602,7 +606,8 @@ export function Plugins() {
|
|||||||
marketplace: pluginMarketplaceStore.getState(),
|
marketplace: pluginMarketplaceStore.getState(),
|
||||||
device: devicePackageStore.getState(),
|
device: devicePackageStore.getState(),
|
||||||
project: codingPluginsStore.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' }),
|
openSettings: () => document.getElementById('plugin-settings')?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export interface PluginWorkspaceDispatchDependencies {
|
|||||||
>;
|
>;
|
||||||
device: Pick<DevicePackageState, 'setEnabled' | 'uninstall'>;
|
device: Pick<DevicePackageState, 'setEnabled' | 'uninstall'>;
|
||||||
project: Pick<CodingPluginsState, 'setEnabled'>;
|
project: Pick<CodingPluginsState, 'setEnabled'>;
|
||||||
navigate(path: string): void;
|
loginReturnPath: string;
|
||||||
|
navigate(path: string, options?: { state?: unknown }): void;
|
||||||
openSettings(): void;
|
openSettings(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ export async function dispatchPluginWorkspaceCommand(
|
|||||||
await dependencies.device.uninstall(command.packageId);
|
await dependencies.device.uninstall(command.packageId);
|
||||||
return;
|
return;
|
||||||
case 'sign_in':
|
case 'sign_in':
|
||||||
dependencies.navigate('/login');
|
dependencies.navigate('/login', { state: { from: dependencies.loginReturnPath } });
|
||||||
return;
|
return;
|
||||||
case 'open_agent_assignment':
|
case 'open_agent_assignment':
|
||||||
dependencies.navigate('/project-config');
|
dependencies.navigate('/project-config');
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export interface PluginWorkspaceItem {
|
|||||||
version: string | null;
|
version: string | null;
|
||||||
delivery: PluginWorkspaceDelivery;
|
delivery: PluginWorkspaceDelivery;
|
||||||
projectState: PluginWorkspaceProjectState;
|
projectState: PluginWorkspaceProjectState;
|
||||||
|
projectPolicyStatus: CodingPluginProject['policyStatus'] | null;
|
||||||
localEnabled: boolean | null;
|
localEnabled: boolean | null;
|
||||||
assignedAgentIds: readonly string[];
|
assignedAgentIds: readonly string[];
|
||||||
assignedAgentNames: readonly string[];
|
assignedAgentNames: readonly string[];
|
||||||
@@ -118,7 +119,12 @@ export interface PluginWorkspaceInputs {
|
|||||||
activeProject: { id: string; name: string } | null;
|
activeProject: { id: string; name: string } | null;
|
||||||
agentNames: Readonly<Record<string, string>>;
|
agentNames: Readonly<Record<string, string>>;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
sourceFailures?: Readonly<{ catalog?: boolean; library?: boolean }>;
|
sourceFailures?: Readonly<{
|
||||||
|
catalog?: boolean;
|
||||||
|
library?: boolean;
|
||||||
|
device?: boolean;
|
||||||
|
project?: boolean;
|
||||||
|
}>;
|
||||||
filters: PluginWorkspaceFilters;
|
filters: PluginWorkspaceFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +300,7 @@ function officialItem(
|
|||||||
? 'account_removed'
|
? 'account_removed'
|
||||||
: input.authenticated && input.library ? 'not_acquired' : 'account_unknown',
|
: input.authenticated && input.library ? 'not_acquired' : 'account_unknown',
|
||||||
projectState,
|
projectState,
|
||||||
|
projectPolicyStatus: record.project ? input.project?.policyStatus ?? null : null,
|
||||||
localEnabled: null,
|
localEnabled: null,
|
||||||
assignedAgentIds: agents.ids,
|
assignedAgentIds: agents.ids,
|
||||||
assignedAgentNames: agents.names,
|
assignedAgentNames: agents.names,
|
||||||
@@ -309,6 +316,7 @@ function officialItem(
|
|||||||
|| input.library?.stale
|
|| input.library?.stale
|
||||||
|| (record.catalog && input.sourceFailures?.catalog)
|
|| (record.catalog && input.sourceFailures?.catalog)
|
||||||
|| (record.library && input.sourceFailures?.library)
|
|| (record.library && input.sourceFailures?.library)
|
||||||
|
|| (record.project && input.sourceFailures?.project)
|
||||||
),
|
),
|
||||||
suspended,
|
suspended,
|
||||||
retired,
|
retired,
|
||||||
@@ -325,7 +333,7 @@ function officialItem(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function localItem(local: DevicePackageRecordV1): PluginWorkspaceItem {
|
function localItem(local: DevicePackageRecordV1, input: PluginWorkspaceInputs): PluginWorkspaceItem {
|
||||||
return {
|
return {
|
||||||
key: `local:${local.packageId}`,
|
key: `local:${local.packageId}`,
|
||||||
source: 'local',
|
source: 'local',
|
||||||
@@ -339,13 +347,14 @@ function localItem(local: DevicePackageRecordV1): PluginWorkspaceItem {
|
|||||||
version: local.resolvedVersion,
|
version: local.resolvedVersion,
|
||||||
delivery: 'local_installed',
|
delivery: 'local_installed',
|
||||||
projectState: 'not_applicable',
|
projectState: 'not_applicable',
|
||||||
|
projectPolicyStatus: null,
|
||||||
localEnabled: local.enabled,
|
localEnabled: local.enabled,
|
||||||
assignedAgentIds: [],
|
assignedAgentIds: [],
|
||||||
assignedAgentNames: [],
|
assignedAgentNames: [],
|
||||||
billing: 'none',
|
billing: 'none',
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
unavailable: false,
|
unavailable: false,
|
||||||
stale: false,
|
stale: Boolean(input.sourceFailures?.device),
|
||||||
suspended: false,
|
suspended: false,
|
||||||
retired: false,
|
retired: false,
|
||||||
deviceReason: null,
|
deviceReason: null,
|
||||||
@@ -381,6 +390,7 @@ function retainedItem(
|
|||||||
version: record?.installation?.version ?? base?.stableVersion ?? null,
|
version: record?.installation?.version ?? base?.stableVersion ?? null,
|
||||||
delivery: 'retained',
|
delivery: 'retained',
|
||||||
projectState: 'unavailable',
|
projectState: 'unavailable',
|
||||||
|
projectPolicyStatus: input.project?.policyStatus ?? null,
|
||||||
localEnabled: null,
|
localEnabled: null,
|
||||||
assignedAgentIds: [],
|
assignedAgentIds: [],
|
||||||
assignedAgentNames: [],
|
assignedAgentNames: [],
|
||||||
@@ -393,6 +403,7 @@ function retainedItem(
|
|||||||
|| (record?.library && input.library?.stale)
|
|| (record?.library && input.library?.stale)
|
||||||
|| (record?.catalog && input.sourceFailures?.catalog)
|
|| (record?.catalog && input.sourceFailures?.catalog)
|
||||||
|| (record?.library && input.sourceFailures?.library)
|
|| (record?.library && input.sourceFailures?.library)
|
||||||
|
|| input.sourceFailures?.project
|
||||||
),
|
),
|
||||||
suspended,
|
suspended,
|
||||||
retired,
|
retired,
|
||||||
@@ -480,7 +491,7 @@ export function buildPluginWorkspaceProjection(input: PluginWorkspaceInputs): Pl
|
|||||||
});
|
});
|
||||||
const localItems = [...(input.devicePackages?.packages ?? [])]
|
const localItems = [...(input.devicePackages?.packages ?? [])]
|
||||||
.sort((left, right) => left.displayName.localeCompare(right.displayName))
|
.sort((left, right) => left.displayName.localeCompare(right.displayName))
|
||||||
.map(localItem);
|
.map((local) => localItem(local, input));
|
||||||
const retainedItems = orderedUnique(input.project?.unknownPluginIds ?? [])
|
const retainedItems = orderedUnique(input.project?.unknownPluginIds ?? [])
|
||||||
.filter((pluginId) => pluginId !== NATIVE_WEB_SEARCH_ID && !records.get(pluginId)?.project)
|
.filter((pluginId) => pluginId !== NATIVE_WEB_SEARCH_ID && !records.get(pluginId)?.project)
|
||||||
.map((pluginId) => retainedItem(
|
.map((pluginId) => retainedItem(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { Login } from '@/pages/Login';
|
||||||
|
import { dispatchPluginWorkspaceCommand } from '@/pages/Plugins/plugin-workspace-controller';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useProviderStore } from '@/stores/providers';
|
import { useProviderStore } from '@/stores/providers';
|
||||||
|
|
||||||
@@ -36,12 +37,63 @@ function resetAuthStore() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLogin() {
|
function LocationProbe() {
|
||||||
|
const location = useLocation();
|
||||||
|
return <output data-testid="login-destination">{location.pathname}{location.search}</output>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginSignInAction() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<output data-testid="login-destination">{location.pathname}{location.search}</output>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void dispatchPluginWorkspaceCommand({ kind: 'sign_in' }, {
|
||||||
|
marketplace: {
|
||||||
|
acquire: async () => undefined,
|
||||||
|
remove: async () => undefined,
|
||||||
|
install: async () => undefined,
|
||||||
|
installBeta: async () => undefined,
|
||||||
|
update: async () => undefined,
|
||||||
|
uninstall: async () => undefined,
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
setEnabled: async () => undefined,
|
||||||
|
uninstall: async () => undefined,
|
||||||
|
},
|
||||||
|
project: { setEnabled: async () => undefined },
|
||||||
|
loginReturnPath: `${location.pathname}${location.search}`,
|
||||||
|
navigate,
|
||||||
|
openSettings: () => undefined,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
登录后获取插件
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogin(initialEntries: React.ComponentProps<typeof MemoryRouter>['initialEntries'] = ['/login']) {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={['/login']}>
|
<MemoryRouter initialEntries={initialEntries}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/module-select" element={<div>Module Selection</div>} />
|
<Route path="/module-select" element={<div>Module Selection</div>} />
|
||||||
|
<Route path="/plugins" element={<LocationProbe />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPluginLoginJourney(canonicalPath: string) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[canonicalPath]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/plugins" element={<PluginSignInAction />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/module-select" element={<div>Module Selection</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
@@ -192,6 +244,39 @@ describe('Login page', () => {
|
|||||||
expect(importUserModelConfig).toHaveBeenCalledWith('password-access-token');
|
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 () => {
|
it('validates a Chinese mobile number, uses one-time-code autocomplete, and submits only phone and SMS code', async () => {
|
||||||
loginWithMobile.mockImplementation(async () => {
|
loginWithMobile.mockImplementation(async () => {
|
||||||
useAuthStore.setState({ accessToken: 'mobile-access-token' });
|
useAuthStore.setState({ accessToken: 'mobile-access-token' });
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function dependencies() {
|
|||||||
project: {
|
project: {
|
||||||
setEnabled: vi.fn().mockResolvedValue(undefined),
|
setEnabled: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
|
loginReturnPath: '/plugins?scope=all&source=official&state=available',
|
||||||
navigate: vi.fn(),
|
navigate: vi.fn(),
|
||||||
openSettings: vi.fn(),
|
openSettings: vi.fn(),
|
||||||
} satisfies PluginWorkspaceDispatchDependencies;
|
} satisfies PluginWorkspaceDispatchDependencies;
|
||||||
@@ -63,7 +64,10 @@ describe('plugin workspace controller', () => {
|
|||||||
]);
|
]);
|
||||||
expect(deps.device.setEnabled.mock.calls).toEqual([['j', true], ['k', false]]);
|
expect(deps.device.setEnabled.mock.calls).toEqual([['j', true], ['k', false]]);
|
||||||
expect(deps.device.uninstall).toHaveBeenCalledExactlyOnceWith('l');
|
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();
|
expect(deps.openSettings).toHaveBeenCalledExactlyOnceWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -694,6 +694,59 @@ describe('buildPluginWorkspaceProjection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps device and project snapshots visible and marks only their retained rows stale after refresh errors', () => {
|
||||||
|
const catalogOnly = {
|
||||||
|
...catalog.items[0]!,
|
||||||
|
pluginId: 'makelore.catalog-only',
|
||||||
|
title: 'Catalog Only',
|
||||||
|
};
|
||||||
|
const result = buildPluginWorkspaceProjection(input({
|
||||||
|
catalog: { ...catalog, items: [...catalog.items, catalogOnly], total: 2 },
|
||||||
|
devicePackages: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generation: 8,
|
||||||
|
packages: [devicePackage('local.cached', 'Cached Local', true)],
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
...project,
|
||||||
|
unknownPluginIds: ['makelore.retained'],
|
||||||
|
},
|
||||||
|
sourceFailures: { device: true, project: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.items.map(({ key }) => key)).toEqual([
|
||||||
|
'official:makelore.notes',
|
||||||
|
'official:makelore.catalog-only',
|
||||||
|
'local:local.cached',
|
||||||
|
'retained:makelore.retained',
|
||||||
|
]);
|
||||||
|
expect(result.items.find(({ key }) => key === 'official:makelore.notes')?.stale).toBe(true);
|
||||||
|
expect(result.items.find(({ key }) => key === 'official:makelore.catalog-only')?.stale).toBe(false);
|
||||||
|
expect(result.items.find(({ key }) => key === 'local:local.cached')?.stale).toBe(true);
|
||||||
|
expect(result.items.find(({ key }) => key === 'retained:makelore.retained')?.stale).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['current', 'stale', 'unavailable'] as const)(
|
||||||
|
'projects %s policy status onto project-backed and retained rows without changing actions',
|
||||||
|
(policyStatus) => {
|
||||||
|
const result = buildPluginWorkspaceProjection(input({
|
||||||
|
project: {
|
||||||
|
...project,
|
||||||
|
policyStatus,
|
||||||
|
unknownPluginIds: ['makelore.retained'],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.items.find(({ key }) => key === 'official:makelore.notes')).toMatchObject({
|
||||||
|
projectPolicyStatus: policyStatus,
|
||||||
|
});
|
||||||
|
expect(result.items.find(({ key }) => key === 'retained:makelore.retained')).toMatchObject({
|
||||||
|
projectPolicyStatus: policyStatus,
|
||||||
|
commands: [{ kind: 'disable_project', projectId: 'project-a', pluginId: 'makelore.retained' }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('offers sign-in for a signed-out free catalog item but fails closed for an authenticated Library failure', () => {
|
it('offers sign-in for a signed-out free catalog item but fails closed for an authenticated Library failure', () => {
|
||||||
const signedOut = buildPluginWorkspaceProjection(input({
|
const signedOut = buildPluginWorkspaceProjection(input({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const officialItem: PluginWorkspaceItem = {
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
delivery: 'system_included',
|
delivery: 'system_included',
|
||||||
projectState: 'enabled',
|
projectState: 'enabled',
|
||||||
|
projectPolicyStatus: 'current',
|
||||||
localEnabled: null,
|
localEnabled: null,
|
||||||
assignedAgentIds: ['agent-a'],
|
assignedAgentIds: ['agent-a'],
|
||||||
assignedAgentNames: ['小明'],
|
assignedAgentNames: ['小明'],
|
||||||
@@ -138,6 +139,7 @@ const localItem: PluginWorkspaceItem = {
|
|||||||
version: '1.2.3',
|
version: '1.2.3',
|
||||||
delivery: 'local_installed',
|
delivery: 'local_installed',
|
||||||
projectState: 'not_applicable',
|
projectState: 'not_applicable',
|
||||||
|
projectPolicyStatus: null,
|
||||||
localEnabled: true,
|
localEnabled: true,
|
||||||
assignedAgentIds: [],
|
assignedAgentIds: [],
|
||||||
assignedAgentNames: [],
|
assignedAgentNames: [],
|
||||||
@@ -289,6 +291,63 @@ describe('PluginsView', () => {
|
|||||||
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
|
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps old device and project rows visible and labels each affected snapshot cached after refresh errors', async () => {
|
||||||
|
const activeProject = {
|
||||||
|
id: 'project-a',
|
||||||
|
name: 'Project A',
|
||||||
|
createdAt: '2026-09-03T00:00:00Z',
|
||||||
|
updatedAt: '2026-09-03T00:00:00Z',
|
||||||
|
lastOpenedAt: '2026-09-03T00:00:00Z',
|
||||||
|
};
|
||||||
|
useAuthStore.setState({ user: null });
|
||||||
|
codingWorkspaceStore.setState({
|
||||||
|
activeProjectId: activeProject.id,
|
||||||
|
activeProject,
|
||||||
|
config: null,
|
||||||
|
loadState: 'ready',
|
||||||
|
});
|
||||||
|
codingPluginsStore.setState({
|
||||||
|
projectId: activeProject.id,
|
||||||
|
projection: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
project: { localProjectId: activeProject.id, durableProjectId: 'durable-a' },
|
||||||
|
policyStatus: 'current',
|
||||||
|
unknownPluginIds: ['makelore.retained'],
|
||||||
|
items: [projectPlugin],
|
||||||
|
},
|
||||||
|
loadState: 'error',
|
||||||
|
error: 'project refresh failed',
|
||||||
|
});
|
||||||
|
devicePackageStore.setState({
|
||||||
|
index: { schemaVersion: 1, generation: 8, packages: [localItem.local!] },
|
||||||
|
state: 'error',
|
||||||
|
error: 'device refresh failed',
|
||||||
|
});
|
||||||
|
pluginMarketplaceStore.setState({
|
||||||
|
accountKey: null,
|
||||||
|
catalog: null,
|
||||||
|
catalogState: 'ready',
|
||||||
|
catalogError: null,
|
||||||
|
library: null,
|
||||||
|
libraryState: 'idle',
|
||||||
|
libraryError: null,
|
||||||
|
installations: {},
|
||||||
|
details: {},
|
||||||
|
detailState: {},
|
||||||
|
});
|
||||||
|
vi.spyOn(pluginMarketplaceStore.getState(), 'loadCatalog').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(devicePackageStore.getState(), 'load').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(codingPluginsStore.getState(), 'load').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<MemoryRouter initialEntries={['/plugins?scope=project&source=all&state=all']}><Plugins /></MemoryRouter>);
|
||||||
|
|
||||||
|
for (const name of ['开发数据服务', 'Pi Web Search', 'makelore.retained']) {
|
||||||
|
const heading = await screen.findByRole('heading', { name });
|
||||||
|
expect(heading.closest('article')).toHaveTextContent('缓存');
|
||||||
|
}
|
||||||
|
expect(screen.getAllByRole('alert')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the unified workspace and exposes URL-backed filter changes', () => {
|
it('renders the unified workspace and exposes URL-backed filter changes', () => {
|
||||||
const view = props();
|
const view = props();
|
||||||
view.sourceErrors = [
|
view.sourceErrors = [
|
||||||
@@ -434,6 +493,24 @@ describe('PluginsView', () => {
|
|||||||
expect(dialog).toHaveTextContent('价格版本:pricing-7');
|
expect(dialog).toHaveTextContent('价格版本:pricing-7');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['stale', '当前项目策略使用缓存,所示能力与价格可能不是最新状态。'],
|
||||||
|
['unavailable', '当前项目策略不可用,无法确认最新能力与价格。'],
|
||||||
|
] as const)('shows %s project policy status beside policy-owned capability and pricing', (projectPolicyStatus, copy) => {
|
||||||
|
const policyItem: PluginWorkspaceItem = { ...officialItem, projectPolicyStatus };
|
||||||
|
render(<MemoryRouter><PluginsView {...props(policyItem)} /></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: '开发数据服务' })).toHaveTextContent(copy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark current project policy as stale or unavailable', () => {
|
||||||
|
render(<MemoryRouter><PluginsView {...props(officialItem)} /></MemoryRouter>);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '开发数据服务' });
|
||||||
|
expect(dialog).not.toHaveTextContent('当前项目策略使用缓存');
|
||||||
|
expect(dialog).not.toHaveTextContent('当前项目策略不可用');
|
||||||
|
});
|
||||||
|
|
||||||
it('deduplicates operation identity while preferring the current project policy projection', () => {
|
it('deduplicates operation identity while preferring the current project policy projection', () => {
|
||||||
const projectPolicy = {
|
const projectPolicy = {
|
||||||
...projectPlugin,
|
...projectPlugin,
|
||||||
|
|||||||
Reference in new issue
Block a user