merge(batch): intègre plugin-activation-scope-loading — chargement du scope d'activation des plugins (vert QA)

This commit is contained in:
2026-08-03 14:44:23 +02:00
111 changed files with 5147 additions and 629 deletions

View File

@ -25,7 +25,13 @@ import { useAgents } from "./useAgents";
import { correlateModelServerStatus } from "./modelServerLaunch";
import { AgentLimitBadge } from "./AgentLimitBadge";
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
import type { ResolvedAgentSystemPermissions } from "@/domain";
import type {
Agent,
AgentProfile,
EffortOption,
EffortSelection,
ResolvedAgentSystemPermissions,
} from "@/domain";
export interface AgentsPanelProps {
/** The project whose agents to manage. */
@ -237,7 +243,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
// Determine if a template is chosen → profile selector is hidden (template imposes it).
const hasTemplate = newTemplateId !== "";
const profileLabel = (profile: import("@/domain").AgentProfile): string => {
const profileLabel = (profile: AgentProfile): string => {
const model =
profile.model ??
profile.opencode?.model ??
@ -365,12 +371,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
const isSelected = a.id === vm.selectedAgentId;
const isRunning = a.id === activeAgentId;
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null;
const profileName =
(() => {
const p = vm.profiles.find((p) => p.id === a.profileId);
return p ? profileLabel(p) : null;
})() ??
a.profileId;
(agentProfile ? profileLabel(agentProfile) : null) ?? a.profileId;
const agentDrift = drift.driftByAgentId.get(a.id);
// Source of this agent's last orchestration delegation (mcp vs
// file), if any has been observed. Absent ⇒ no badge.
@ -480,6 +483,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
]}
/>
)}
<EffortSelector
agent={a}
profile={agentProfile}
busy={vm.busy}
onChange={(effort) =>
void vm.updateAgentEffort(a.id, effort)
}
/>
{agentDrift && (
<Button
size="sm"
@ -706,6 +717,141 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
);
}
const EFFORT_DEFAULT_VALUE = "__profile_default__";
const EFFORT_CUSTOM_VALUE = "__custom_effort__";
const GENERIC_EFFORT_OPTIONS: EffortOption[] = [
{
value: "low",
label: "Rapide (par défaut)",
hint: "Fallback générique léger.",
},
{
value: "medium",
label: "Standard (par défaut)",
hint: "Fallback générique équilibré.",
},
{
value: "high",
label: "Approfondi (par défaut)",
hint: "Fallback générique profond.",
},
];
function rawEffortValue(selection: EffortSelection | undefined): string {
return selection?.value ?? "";
}
function EffortSelector({
agent,
profile,
busy,
onChange,
}: {
agent: Agent;
profile: AgentProfile | null;
busy: boolean;
onChange: (effort: EffortSelection | null) => void;
}) {
const nativeOptions = profile?.effortOptions ?? [];
const hasNativeOptions = nativeOptions.length > 0;
const displayedOptions = hasNativeOptions
? nativeOptions
: GENERIC_EFFORT_OPTIONS;
const [customText, setCustomText] = useState(rawEffortValue(agent.effort));
const [forceCustom, setForceCustom] = useState(agent.effort?.kind === "custom");
useEffect(() => {
setCustomText(rawEffortValue(agent.effort));
setForceCustom(agent.effort?.kind === "custom");
}, [agent.id, agent.effort]);
let selectedOption = EFFORT_DEFAULT_VALUE;
if (forceCustom) {
selectedOption = EFFORT_CUSTOM_VALUE;
} else if (
hasNativeOptions &&
agent.effort?.kind === "preset" &&
nativeOptions.some((option) => option.value === agent.effort?.value)
) {
selectedOption = `preset:${agent.effort.value}`;
} else if (
!hasNativeOptions &&
agent.effort &&
displayedOptions.some((option) => option.value === agent.effort?.value)
) {
selectedOption = `fallback:${agent.effort.value}`;
} else if (agent.effort) {
selectedOption = EFFORT_CUSTOM_VALUE;
}
const customVisible = selectedOption === EFFORT_CUSTOM_VALUE;
function commitCustom() {
const value = customText.trim();
if (value.length === 0) return;
if (agent.effort?.kind === "custom" && agent.effort.value === value) return;
onChange({ kind: "custom", value });
}
return (
<span className="flex min-w-[11rem] max-w-full flex-wrap items-center gap-1.5">
<SmallDropdown
aria-label={`effort for ${agent.name}`}
value={selectedOption}
disabled={busy}
onChange={(value) => {
if (value === EFFORT_DEFAULT_VALUE) {
setForceCustom(false);
onChange(null);
return;
}
if (value === EFFORT_CUSTOM_VALUE) {
setForceCustom(true);
setCustomText(rawEffortValue(agent.effort));
return;
}
if (value.startsWith("preset:")) {
setForceCustom(false);
onChange({ kind: "preset", value: value.slice("preset:".length) });
return;
}
if (value.startsWith("fallback:")) {
setForceCustom(false);
onChange({ kind: "custom", value: value.slice("fallback:".length) });
}
}}
options={[
{ value: EFFORT_DEFAULT_VALUE, label: "Effort: profil" },
...displayedOptions.map((option) => ({
value: `${hasNativeOptions ? "preset" : "fallback"}:${option.value}`,
label: option.label,
})),
{ value: EFFORT_CUSTOM_VALUE, label: "Personnalisé" },
]}
/>
{customVisible && (
<Input
aria-label={`custom effort for ${agent.name}`}
value={customText}
disabled={busy}
placeholder="valeur brute"
onChange={(event) => setCustomText(event.target.value)}
onBlur={commitCustom}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
commitCustom();
}
}}
className="h-8 min-w-[8rem] flex-1 px-2 text-xs"
/>
)}
</span>
);
}
function NetworkPermissionBadge({
state,
}: {

View File

@ -241,6 +241,43 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
});
});
it("reopening the agents panel reloads the saved context as textarea text", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, {
name: "Reopen",
profileId: "p1",
initialContent: "initial",
});
const firstRender = renderPanel(agent);
await waitForIdle();
let buttons = screen.getAllByRole("button", { name: /reopen/i });
let rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
fireEvent.click(rowBtn);
let textarea = await screen.findByLabelText("agent context");
fireEvent.change(textarea, { target: { value: "persisted after reopen" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID);
await expect(agent.readContext(PROJECT_ID, agents[0].id)).resolves.toBe(
"persisted after reopen",
);
});
firstRender.unmount();
renderPanel(agent);
await waitForIdle();
buttons = screen.getAllByRole("button", { name: /reopen/i });
rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
fireEvent.click(rowBtn);
textarea = await screen.findByLabelText("agent context");
expect((textarea as HTMLTextAreaElement).value).toBe("persisted after reopen");
});
it("deleting an agent removes it from the list", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, {
@ -492,6 +529,11 @@ async function seededProfiles(): Promise<MockProfileGateway> {
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
detect: null,
cwdTemplate: "{projectRoot}",
effortOptions: [
{ value: "low", label: "Léger" },
{ value: "medium", label: "Standard" },
{ value: "high", label: "Profond" },
],
},
{
id: "prof-2",
@ -588,6 +630,123 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
});
});
describe("AgentsPanel effort selection (#131)", () => {
it("shows native profile effort options in declaration order with Personnalisé last", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, { name: "Thinker", profileId: "prof-1" });
const profile = await seededProfiles();
renderPanel(agent, profile);
await waitForIdle();
await screen.findByText("Thinker");
openDropdown("effort for Thinker");
const labels = screen
.getAllByRole("option")
.map((option) => option.textContent);
expect(labels).toEqual([
"Effort: profil",
"Léger",
"Standard",
"Profond",
"Personnalisé",
]);
});
it("persists a native effort option as a preset", async () => {
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Thinker",
profileId: "prof-1",
});
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
renderPanel(agent, await seededProfiles());
await waitForIdle();
await screen.findByText("Thinker");
chooseDropdownOption("effort for Thinker", "Profond");
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
kind: "preset",
value: "high",
});
});
const [updated] = await agent.listAgents(PROJECT_ID);
expect(updated.effort).toEqual({ kind: "preset", value: "high" });
});
it("shows generic default fallback options when the profile declares no native options", async () => {
const agent = new MockAgentGateway();
const profile = new MockProfileGateway();
await profile.configureProfiles([
{
id: "plain",
name: "Plain provider",
command: "plain-ai",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
},
]);
const created = await agent.createAgent(PROJECT_ID, {
name: "Fallback",
profileId: "plain",
});
renderPanel(agent, profile);
await waitForIdle();
await screen.findByText("Fallback");
openDropdown("effort for Fallback");
const labels = screen
.getAllByRole("option")
.map((option) => option.textContent);
expect(labels).toEqual([
"Effort: profil",
"Rapide (par défaut)",
"Standard (par défaut)",
"Approfondi (par défaut)",
"Personnalisé",
]);
fireEvent.click(screen.getByRole("option", { name: "Standard (par défaut)" }));
await waitFor(async () => {
const [updated] = await agent.listAgents(PROJECT_ID);
expect(updated.id).toBe(created.id);
expect(updated.effort).toEqual({ kind: "custom", value: "medium" });
});
});
it("reveals an inline custom effort field and persists the free text", async () => {
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Custom",
profileId: "prof-1",
});
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
renderPanel(agent, await seededProfiles());
await waitForIdle();
await screen.findByText("Custom");
chooseDropdownOption("effort for Custom", "Personnalisé");
const input = screen.getByLabelText("custom effort for Custom");
fireEvent.change(input, { target: { value: "x-provider-deep" } });
fireEvent.blur(input);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
kind: "custom",
value: "x-provider-deep",
});
});
});
});
describe("AgentsPanel live refresh on domain events", () => {
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
const agent = new MockAgentGateway();

View File

@ -12,6 +12,7 @@ import { useCallback, useEffect, useState } from "react";
import type {
Agent,
AgentProfile,
EffortSelection,
GatewayError,
ModelServerStatus,
TerminalSession,
@ -124,6 +125,11 @@ export interface AgentsViewModel {
rows: number,
cols: number,
) => Promise<TerminalSession | undefined>;
/** Sets or clears a per-agent effort override. */
updateAgentEffort: (
agentId: string,
effort: EffortSelection | null,
) => Promise<void>;
/** Deletes an agent; deselects if it was selected. */
deleteAgent: (agentId: string) => Promise<void>;
/**
@ -427,6 +433,26 @@ export function useAgents(projectId: string): AgentsViewModel {
[agent, projectId, refreshLiveAgents],
);
const updateAgentEffort = useCallback(
async (agentId: string, effort: EffortSelection | null): Promise<void> => {
setBusy(true);
setError(null);
try {
const updated = await agent.updateAgentEffort(projectId, agentId, effort);
setAgents((prev) =>
prev.map((candidate) =>
candidate.id === updated.id ? updated : candidate,
),
);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId],
);
const deleteAgent = useCallback(
async (agentId: string) => {
setBusy(true);
@ -544,6 +570,7 @@ export function useAgents(projectId: string): AgentsViewModel {
selectAgent,
saveContext,
changeAgentProfile,
updateAgentEffort,
deleteAgent,
launchAgent,
stopAgent,

View File

@ -8,8 +8,10 @@ import type {
PermissionPosture,
PermissionRule,
PermissionSet,
PermissionShadowReport,
ProjectPermissions,
ProjectSystemPermissions,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain";
@ -27,6 +29,7 @@ export interface PolicyDraft {
export interface AgentPermissionRow {
agent: Agent;
override: PermissionSet | null;
shadowed: PermissionShadowReport | null;
systemOverride: SystemPermissionSet | null;
resolvedSystem: ResolvedAgentSystemPermissions | null;
}
@ -132,6 +135,9 @@ export function usePermissions(projectId: string): PermissionsViewModel {
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
Record<string, ResolvedAgentSystemPermissions>
>({});
const [resolvedPermissionsByAgent, setResolvedPermissionsByAgent] = useState<
Record<string, ResolvedAgentPermissions>
>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -156,9 +162,29 @@ export function usePermissions(projectId: string): PermissionsViewModel {
}
}),
);
const resolvedPermissionPairs = await Promise.all(
agentList.map(async (candidate) => {
try {
return [
candidate.id,
await permission.resolveAgentPermissions(projectId, candidate.id),
] as const;
} catch {
return [candidate.id, null] as const;
}
}),
);
setAgents(agentList);
setDocument(permissionDoc);
setSystemDocument(systemPermissionDoc);
setResolvedPermissionsByAgent(
Object.fromEntries(
resolvedPermissionPairs.filter(
(pair): pair is readonly [string, ResolvedAgentPermissions] =>
pair[1] !== null,
),
),
);
setResolvedSystemByAgent(
Object.fromEntries(
resolvedPairs.filter(
@ -191,10 +217,17 @@ export function usePermissions(projectId: string): PermissionsViewModel {
return agents.map((candidate) => ({
agent: candidate,
override: overrides.get(candidate.id) ?? null,
shadowed: resolvedPermissionsByAgent[candidate.id]?.shadowed ?? null,
systemOverride: systemOverrides.get(candidate.id) ?? null,
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
}));
}, [agents, document, systemDocument, resolvedSystemByAgent]);
}, [
agents,
document,
systemDocument,
resolvedPermissionsByAgent,
resolvedSystemByAgent,
]);
const projectDraft = useMemo(
() => draftFromSet(document?.projectDefaults ?? null),

View File

@ -73,7 +73,7 @@ function renderCell(
const gateways: Gateways = createMockGateways();
return render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
<PluginRuntimeProvider value={{ registry, failures: [], pending: [], loading: false }}>
<PluginLayoutCellView
projectId="proj-1"
cell={props.cell ?? cell()}

View File

@ -13,12 +13,19 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { loadPlugins, PluginRuntimeRegistry, type PluginLoadFailure } from "@/plugins/runtime";
import {
loadPlugins,
PluginRuntimeRegistry,
type PluginLoadFailure,
type PluginLoadPending,
} from "@/plugins/runtime";
import { useGateways } from "@/app/di";
import type { PluginRuntimePlugin, Unsubscribe } from "@/domain";
export interface PluginRuntimeContextValue {
registry: PluginRuntimeRegistry;
failures: PluginLoadFailure[];
pending: PluginLoadPending[];
/** True until the initial catalog fetch + bundle loads have settled. */
loading: boolean;
}
@ -33,6 +40,7 @@ export interface PluginRuntimeContextValue {
const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = {
registry: new PluginRuntimeRegistry(),
failures: [],
pending: [],
loading: false,
};
@ -57,6 +65,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
injected ?? {
registry: new PluginRuntimeRegistry(),
failures: [],
pending: [],
loading: true,
},
);
@ -64,27 +73,85 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
useEffect(() => {
if (injected) return;
let cancelled = false;
const pluginGateways = {
project: gateways.project,
git: gateways.git,
terminal: gateways.terminal,
agents: gateways.agent,
system: gateways.system,
workState: gateways.workState,
focusedProject: gateways.focusedProject,
pluginWorkspace: gateways.pluginWorkspace,
pluginTask: gateways.pluginTask,
pluginToolchain: gateways.pluginToolchain,
pluginEvents: gateways.pluginEvents,
pluginConfig: gateways.pluginConfig,
pluginStorage: gateways.pluginStorage,
};
let activatedProjectScoped = false;
let pendingProjectPlugins: PluginRuntimePlugin[] = [];
let unsubscribeFocus: Unsubscribe | undefined;
void gateways.focusedProject
.onFocusedProjectChanged(async (project) => {
if (!project || activatedProjectScoped || pendingProjectPlugins.length === 0) return;
activatedProjectScoped = true;
const projectPlugins = pendingProjectPlugins;
pendingProjectPlugins = [];
try {
const result = await loadPlugins(projectPlugins, pluginGateways);
if (cancelled) return;
setValue((prev) => {
for (const plugin of result.registry.list()) prev.registry.add(plugin);
return {
registry: prev.registry,
failures: [...prev.failures, ...result.failures],
pending: prev.pending.filter(
(p) => !projectPlugins.some((entry) => entry.id === p.pluginId),
),
loading: false,
};
});
} catch (e: unknown) {
if (cancelled) return;
setValue((prev) => ({
...prev,
failures: [
...prev.failures,
...projectPlugins.map((plugin) => ({
pluginId: plugin.id,
reason: describeError(e),
})),
],
pending: prev.pending.filter(
(p) => !projectPlugins.some((entry) => entry.id === p.pluginId),
),
loading: false,
}));
}
})
.then((unsubscribe) => {
if (cancelled) unsubscribe();
else unsubscribeFocus = unsubscribe;
});
gateways.plugin
.listRuntimeContributions()
.then((catalog) =>
loadPlugins(catalog.plugins, {
project: gateways.project,
git: gateways.git,
terminal: gateways.terminal,
agents: gateways.agent,
system: gateways.system,
workState: gateways.workState,
focusedProject: gateways.focusedProject,
pluginWorkspace: gateways.pluginWorkspace,
pluginTask: gateways.pluginTask,
pluginToolchain: gateways.pluginToolchain,
pluginEvents: gateways.pluginEvents,
pluginConfig: gateways.pluginConfig,
}),
)
.then(async (catalog) => {
const result = await loadPlugins(catalog.plugins, pluginGateways);
pendingProjectPlugins = catalog.plugins.filter((entry) =>
result.pending.some((p) => p.pluginId === entry.id),
);
return result;
})
.then((result) => {
if (cancelled) return;
setValue({ registry: result.registry, failures: result.failures, loading: false });
setValue({
registry: result.registry,
failures: result.failures,
pending: result.pending,
loading: false,
});
})
.catch((e: unknown) => {
// No plugin gateway / catalog fetch failed: run with zero plugins
@ -96,12 +163,14 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
...prev.failures,
{ pluginId: "<runtime-catalog>", reason: describeError(e) },
],
pending: [],
loading: false,
}));
}
});
return () => {
cancelled = true;
unsubscribeFocus?.();
};
// Gateways are a stable singleton for the app session (from `useGateways`);
// re-running on every render would reload every plugin bundle.

View File

@ -128,6 +128,23 @@ export function PluginsPanel() {
</Panel>
)}
{pluginRuntime.pending.length > 0 && (
<Panel>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-content">
Certains plugins attendent un projet actif.
</p>
<ul className="flex flex-col gap-0.5">
{pluginRuntime.pending.map((pending) => (
<li key={pending.pluginId} className="text-xs text-muted">
<span className="font-medium text-content">{pending.displayName}</span>
</li>
))}
</ul>
</div>
</Panel>
)}
{vm.plugins.length === 0 ? (
<Panel>
<p className="text-sm text-muted">Aucun plugin installé.</p>

View File

@ -6,7 +6,12 @@
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
import {
createMockGateways,
MockFocusedProjectGateway,
MockPluginGateway,
MockSystemGateway,
} from "@/adapters/mock";
import type { PluginInstallResult, PluginReview, PluginRuntimeContributionCatalog } from "@/domain";
import type { Gateways, ReviewPluginPackageInput } from "@/ports";
import { DIProvider } from "@/app/di";
@ -14,12 +19,17 @@ import { PluginRuntimeRegistry } from "@/plugins/runtime";
import { PluginsPanel } from "./PluginsPanel";
import { PluginRuntimeProvider, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
function dataUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
}
function renderPanel(
plugin?: MockPluginGateway,
system?: MockSystemGateway,
runtimeValue: PluginRuntimeContextValue = {
registry: new PluginRuntimeRegistry(),
failures: [],
pending: [],
loading: false,
},
) {
@ -40,7 +50,7 @@ function renderPanel(
}
function renderPanelWithLiveRuntime(plugin: MockPluginGateway, system = new MockSystemGateway()) {
const gateways = { plugin, system } as unknown as Gateways;
const gateways = { ...createMockGateways(), plugin, system };
return render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider>
@ -77,6 +87,63 @@ class FailingRuntimeCatalogPluginGateway extends MockPluginGateway {
}
}
class ProjectScopedRuntimeCatalogPluginGateway extends MockPluginGateway {
constructor(private readonly bundleUrl: string) {
super();
}
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
return {
plugins: [
{
id: "dev.acme.project-plugin",
displayName: "Project Plugin",
version: "1.0.0",
activationScope: "project",
bundleUrl: this.bundleUrl,
contentHash: "project-plugin",
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
},
],
};
}
}
class MixedActivationScopeRuntimeCatalogPluginGateway extends MockPluginGateway {
constructor(
private readonly failingAppBundleUrl: string,
private readonly projectBundleUrl: string,
) {
super();
}
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
return {
plugins: [
{
id: "dev.acme.app-needs-project",
displayName: "App Needs Project",
version: "1.0.0",
capabilities: ["tooling"],
activationScope: "app",
bundleUrl: this.failingAppBundleUrl,
contentHash: "app-needs-project",
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
},
{
id: "dev.acme.project-plugin",
displayName: "Project Plugin",
version: "1.0.0",
activationScope: "project",
bundleUrl: this.projectBundleUrl,
contentHash: "project-plugin",
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
},
],
};
}
}
class BackendShapedReviewPluginGateway extends MockPluginGateway {
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
const label = input.path.split("/").pop() ?? input.path;
@ -106,6 +173,7 @@ describe("PluginsPanel", () => {
renderPanel(undefined, undefined, {
registry: new PluginRuntimeRegistry(),
failures: [{ pluginId: "com.example.hello-plugin", reason: "Cannot use import statement outside a module" }],
pending: [],
loading: false,
});
@ -124,6 +192,130 @@ describe("PluginsPanel", () => {
expect(screen.getByText(/runtime catalog failed/)).toBeTruthy();
});
it("renders runtime failures before pending plugins without repeating invariant pending reasons", async () => {
renderPanel(undefined, undefined, {
registry: new PluginRuntimeRegistry(),
failures: [{ pluginId: "dev.acme.failed", reason: "activation failed" }],
pending: [
{
pluginId: "dev.acme.pending",
displayName: "Pending Plugin",
reason: "En attente d'un projet actif.",
},
],
loading: false,
});
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
const failureTitle = screen.getByText("Certains plugins installés n'ont pas pu être chargés.");
const pendingTitle = screen.getByText("Certains plugins attendent un projet actif.");
expect(
failureTitle.compareDocumentPosition(pendingTitle) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(screen.getByText("Pending Plugin")).toBeTruthy();
expect(screen.queryByText("En attente d'un projet actif.")).toBeNull();
});
it("shows project-scoped runtime plugins as pending until a project is focused", async () => {
delete (globalThis as Record<string, unknown>).__projectPluginActivations;
delete (globalThis as Record<string, unknown>).__projectPluginActivatedWith;
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__projectPluginActivations = (globalThis.__projectPluginActivations ?? 0) + 1;
globalThis.__projectPluginActivatedWith = ctx.pluginId;
}
`);
const focusedProject = new MockFocusedProjectGateway();
const gateways = {
...createMockGateways(),
focusedProject,
plugin: new ProjectScopedRuntimeCatalogPluginGateway(bundle),
};
render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider>
<PluginsPanel />
</PluginRuntimeProvider>
</DIProvider>,
);
expect(await screen.findByText("Certains plugins attendent un projet actif.")).toBeTruthy();
expect(screen.getByText("Project Plugin")).toBeTruthy();
expect(screen.queryByText("Certains plugins installés n'ont pas pu être chargés.")).toBeNull();
await focusedProject.setFocusedProject({ id: "p1", name: "Alpha", root: "/tmp/alpha" });
await waitFor(() => {
expect(screen.queryByText("Certains plugins attendent un projet actif.")).toBeNull();
expect((globalThis as Record<string, unknown>).__projectPluginActivatedWith).toBe(
"dev.acme.project-plugin",
);
});
await focusedProject.setFocusedProject({ id: "p2", name: "Beta", root: "/tmp/beta" });
expect((globalThis as Record<string, unknown>).__projectPluginActivations).toBe(1);
});
it("keeps app-scope failures distinct from project-scope pending plugins, without cross-blocking", async () => {
delete (globalThis as Record<string, unknown>).__mixedProjectPluginActivations;
delete (globalThis as Record<string, unknown>).__mixedProjectPluginActivatedWith;
const failingAppBundle = dataUrl(`
export async function activate(ctx) {
await ctx.services.workspace.getProjectRoot();
}
`);
const projectBundle = dataUrl(`
export function activate(ctx) {
globalThis.__mixedProjectPluginActivations =
(globalThis.__mixedProjectPluginActivations ?? 0) + 1;
globalThis.__mixedProjectPluginActivatedWith = ctx.pluginId;
}
`);
const focusedProject = new MockFocusedProjectGateway();
const gateways = {
...createMockGateways(),
focusedProject,
plugin: new MixedActivationScopeRuntimeCatalogPluginGateway(
failingAppBundle,
projectBundle,
),
};
render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider>
<PluginsPanel />
</PluginRuntimeProvider>
</DIProvider>,
);
const failureTitle = await screen.findByText("Certains plugins installés n'ont pas pu être chargés.");
const failureSection = failureTitle.closest("section");
expect(failureSection).not.toBeNull();
expect(within(failureSection as HTMLElement).getByText("dev.acme.app-needs-project")).toBeTruthy();
expect(
within(failureSection as HTMLElement).getByText((_, element) =>
element?.tagName === "LI" &&
(element.textContent?.includes("no current project is focused") ?? false),
),
).toBeTruthy();
expect(screen.getByText("Certains plugins attendent un projet actif.")).toBeTruthy();
expect(screen.getByText("Project Plugin")).toBeTruthy();
await focusedProject.setFocusedProject({ id: "p1", name: "Alpha", root: "/tmp/alpha" });
await waitFor(() => {
expect(screen.queryByText("Certains plugins attendent un projet actif.")).toBeNull();
expect((globalThis as Record<string, unknown>).__mixedProjectPluginActivatedWith).toBe(
"dev.acme.project-plugin",
);
});
await focusedProject.setFocusedProject({ id: "p2", name: "Beta", root: "/tmp/beta" });
expect((globalThis as Record<string, unknown>).__mixedProjectPluginActivations).toBe(1);
});
it("installs from an archive via the review dialog, mentioning full-trust", async () => {
renderPanel();
await screen.findByText("Aucun plugin installé.");

View File

@ -118,7 +118,7 @@ function renderWithPlugin(git: GitGateway) {
return render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
<PluginRuntimeProvider value={{ registry, failures: [], pending: [], loading: false }}>
<ProjectsView />
</PluginRuntimeProvider>
</DIProvider>,