feat(plugins): soft refresh UI des contributions plugins après install/reload — frontend #279 (PluginRuntimeProvider refreshRuntime sur événements pluginInstalled/pluginReloaded, dispose de l'ancien registry, catalogue listRuntimeContributions, gateway mock; QA verte ré-exécutée: 35/35 tests features/plugins dont nouveau PluginRuntimeProvider.test, typecheck OK)
This commit is contained in:
@ -4051,12 +4051,12 @@ export class MockPluginGateway implements PluginGateway {
|
||||
lifecycleState: "enabled",
|
||||
enabled: true,
|
||||
pendingUninstall: false,
|
||||
restartRequired: true,
|
||||
restartRequired: false,
|
||||
trustLevel: "full",
|
||||
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
|
||||
};
|
||||
this.plugins.push(plugin);
|
||||
return { plugin: structuredClone(plugin), restartRequired: true };
|
||||
return { plugin: structuredClone(plugin), restartRequired: false };
|
||||
}
|
||||
|
||||
installFromArchive(path: string): Promise<PluginInstallResult> {
|
||||
|
||||
@ -282,6 +282,14 @@ export interface EmbeddedServerStatus {
|
||||
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||
export type DomainEvent =
|
||||
| { type: "projectCreated"; projectId: string }
|
||||
| { type: "pluginInstalled"; pluginId: string; version: string }
|
||||
| {
|
||||
type: "pluginReloaded";
|
||||
pluginId: string;
|
||||
oldContentHash: string;
|
||||
newContentHash: string;
|
||||
changed: boolean;
|
||||
}
|
||||
| { type: "agentLaunched"; agentId: string; sessionId: string }
|
||||
| { type: "agentExited"; agentId: string; code: number }
|
||||
| {
|
||||
|
||||
182
frontend/src/features/plugins/PluginRuntimeProvider.test.tsx
Normal file
182
frontend/src/features/plugins/PluginRuntimeProvider.test.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createMockGateways, MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type {
|
||||
CustomPluginLayoutCell,
|
||||
PluginRuntimeContributionCatalog,
|
||||
PluginRuntimePlugin,
|
||||
} from "@/domain";
|
||||
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||
import { PluginLayoutCellView } from "./PluginLayoutCellView";
|
||||
import { PluginRuntimeProvider } from "./PluginRuntimeProvider";
|
||||
|
||||
function dataUrl(source: string): string {
|
||||
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
}
|
||||
|
||||
function runtimePlugin(overrides: Partial<PluginRuntimePlugin> = {}): PluginRuntimePlugin {
|
||||
return {
|
||||
id: "dev.acme.refreshable",
|
||||
displayName: "Refreshable v1",
|
||||
version: "1.0.0",
|
||||
bundleUrl: dataUrl("export function activate() {}"),
|
||||
contentHash: "v1",
|
||||
contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class MutableRuntimeCatalogGateway extends MockPluginGateway {
|
||||
calls = 0;
|
||||
catalog: PluginRuntimeContributionCatalog = { plugins: [] };
|
||||
|
||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
this.calls += 1;
|
||||
return this.catalog;
|
||||
}
|
||||
}
|
||||
|
||||
function RuntimeProbe({ onMount }: { onMount: () => void }) {
|
||||
const { registry, loading } = usePluginRuntime();
|
||||
const [workspaceState, setWorkspaceState] = useState("initial");
|
||||
|
||||
useEffect(onMount, [onMount]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="runtime-plugin">
|
||||
{loading ? "loading" : (registry.list()[0]?.displayName ?? "none")}
|
||||
</span>
|
||||
<span data-testid="workspace-state">{workspaceState}</span>
|
||||
<button onClick={() => setWorkspaceState("preserved")}>change workspace</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
||||
it("reloads contributions after install and reload events without remounting its workspace", async () => {
|
||||
const plugin = new MutableRuntimeCatalogGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const gateways = { ...createMockGateways(), plugin, system };
|
||||
let mounts = 0;
|
||||
const onMount = () => {
|
||||
mounts += 1;
|
||||
};
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider>
|
||||
<RuntimeProbe onMount={onMount} />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("none")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "change workspace" }));
|
||||
|
||||
plugin.catalog = { plugins: [runtimePlugin()] };
|
||||
system.emit({
|
||||
type: "pluginInstalled",
|
||||
pluginId: "dev.acme.refreshable",
|
||||
version: "1.0.0",
|
||||
});
|
||||
expect(await screen.findByText("Refreshable v1")).toBeTruthy();
|
||||
|
||||
plugin.catalog = {
|
||||
plugins: [
|
||||
runtimePlugin({
|
||||
displayName: "Refreshable v2",
|
||||
version: "2.0.0",
|
||||
contentHash: "v2",
|
||||
bundleUrl: dataUrl("export function activate() { return { dispose() {} }; }"),
|
||||
}),
|
||||
],
|
||||
};
|
||||
system.emit({
|
||||
type: "pluginReloaded",
|
||||
pluginId: "dev.acme.refreshable",
|
||||
oldContentHash: "v1",
|
||||
newContentHash: "v2",
|
||||
changed: true,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Refreshable v2")).toBeTruthy();
|
||||
expect(screen.getByTestId("workspace-state").textContent).toBe("preserved");
|
||||
expect(mounts).toBe(1);
|
||||
expect(plugin.calls).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps a custom plugin layout cell mounted as a fallback when its contribution disappears", async () => {
|
||||
const plugin = new MutableRuntimeCatalogGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const gateways = { ...createMockGateways(), plugin, system };
|
||||
const testCell: CustomPluginLayoutCell = {
|
||||
id: "plugin-cell-1",
|
||||
pluginId: "dev.acme.refreshable",
|
||||
layoutType: "dev.acme.refreshable.layout",
|
||||
state: { tab: "history" },
|
||||
};
|
||||
plugin.catalog = {
|
||||
plugins: [
|
||||
runtimePlugin({
|
||||
bundleUrl: dataUrl(`
|
||||
export function activate(ctx) {
|
||||
ctx.layouts.register({
|
||||
type: "dev.acme.refreshable.layout",
|
||||
component: () => "Plugin layout"
|
||||
});
|
||||
}
|
||||
`),
|
||||
contributes: {
|
||||
menus: [],
|
||||
menuItems: [],
|
||||
layouts: [
|
||||
{
|
||||
type: "dev.acme.refreshable.layout",
|
||||
label: "Refreshable layout",
|
||||
component: "RefreshableLayout"
|
||||
}
|
||||
],
|
||||
mcpServers: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider>
|
||||
<PluginLayoutCellView
|
||||
projectId="project-1"
|
||||
cell={testCell}
|
||||
onStateChange={() => {}}
|
||||
onOpenPlugins={() => {}}
|
||||
onChooseAnotherLayout={() => {}}
|
||||
/>
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Plugin layout")).toBeTruthy();
|
||||
plugin.catalog = { plugins: [] };
|
||||
system.emit({
|
||||
type: "pluginReloaded",
|
||||
pluginId: testCell.pluginId,
|
||||
oldContentHash: "v1",
|
||||
newContentHash: "v2",
|
||||
changed: true,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Layout indisponible")).toBeTruthy();
|
||||
expect(testCell).toEqual({
|
||||
id: "plugin-cell-1",
|
||||
pluginId: "dev.acme.refreshable",
|
||||
layoutType: "dev.acme.refreshable.layout",
|
||||
state: { tab: "history" },
|
||||
});
|
||||
await waitFor(() => expect(plugin.calls).toBe(2));
|
||||
});
|
||||
});
|
||||
@ -1,14 +1,13 @@
|
||||
/**
|
||||
* `PluginRuntimeProvider` — bootstraps the plugin runtime once per app session
|
||||
* (ticket #43, F1, carnet §1.3): fetches the runtime catalog (already filtered
|
||||
* `PluginRuntimeProvider` — owns the refreshable plugin runtime for one window.
|
||||
* It fetches the runtime catalog (already filtered
|
||||
* to `enabled && !pendingUninstall` by the backend) and loads every bundle via
|
||||
* {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry}
|
||||
* to the rest of the tree (menus in F3, layouts in F4).
|
||||
*
|
||||
* Mounted once near the app root, inside `<DIProvider>` (it reads gateways via
|
||||
* `useGateways()`). Never re-runs within a session — carnet §1.3: disable/
|
||||
* uninstall only mask contributions in-session; the actual reload happens on
|
||||
* the next app start (a fresh mount of this provider).
|
||||
* Mounted near the window root, inside `<DIProvider>` (it reads gateways via
|
||||
* `useGateways()`). Install/reload domain events rebuild only this context value:
|
||||
* the workspace below stays mounted and retains all non-plugin UI/runtime state.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
@ -73,6 +72,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
useEffect(() => {
|
||||
if (injected) return;
|
||||
let cancelled = false;
|
||||
let refreshRevision = 0;
|
||||
const pluginGateways = {
|
||||
project: gateways.project,
|
||||
git: gateways.git,
|
||||
@ -89,89 +89,78 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
pluginConfig: gateways.pluginConfig,
|
||||
pluginStorage: gateways.pluginStorage,
|
||||
};
|
||||
let activatedProjectScoped = false;
|
||||
let pendingProjectPlugins: PluginRuntimePlugin[] = [];
|
||||
let unsubscribeFocus: Unsubscribe | undefined;
|
||||
let unsubscribeDomainEvents: Unsubscribe | undefined;
|
||||
let activeRegistry = value.registry;
|
||||
|
||||
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;
|
||||
});
|
||||
async function disposeRegistry(registry: PluginRuntimeRegistry): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
registry.list().map((plugin) => registry.remove(plugin.pluginId)),
|
||||
);
|
||||
}
|
||||
|
||||
gateways.plugin
|
||||
.listRuntimeContributions()
|
||||
.then(async (catalog) => {
|
||||
async function refreshRuntime(initial: boolean): Promise<void> {
|
||||
const revision = ++refreshRevision;
|
||||
try {
|
||||
const catalog = await gateways.plugin.listRuntimeContributions();
|
||||
const result = await loadPlugins(catalog.plugins, pluginGateways);
|
||||
if (cancelled || revision !== refreshRevision) {
|
||||
await disposeRegistry(result.registry);
|
||||
return;
|
||||
}
|
||||
pendingProjectPlugins = catalog.plugins.filter((entry) =>
|
||||
result.pending.some((p) => p.pluginId === entry.id),
|
||||
result.pending.some((pending) => pending.pluginId === entry.id),
|
||||
);
|
||||
return result;
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
const previousRegistry = activeRegistry;
|
||||
activeRegistry = result.registry;
|
||||
setValue({
|
||||
registry: result.registry,
|
||||
failures: result.failures,
|
||||
pending: result.pending,
|
||||
loading: false,
|
||||
});
|
||||
if (previousRegistry !== result.registry) void disposeRegistry(previousRegistry);
|
||||
} catch (e: unknown) {
|
||||
if (cancelled || revision !== refreshRevision) return;
|
||||
const failure = { pluginId: "<runtime-catalog>", reason: describeError(e) };
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
failures: initial ? [failure] : [...prev.failures, failure],
|
||||
pending: initial ? [] : prev.pending,
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
void gateways.focusedProject
|
||||
.onFocusedProjectChanged((project) => {
|
||||
if (!project || pendingProjectPlugins.length === 0) return;
|
||||
void refreshRuntime(false);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// No plugin gateway / catalog fetch failed: run with zero plugins
|
||||
// rather than blocking the app (full-trust plugins are additive).
|
||||
if (!cancelled) {
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
failures: [
|
||||
...prev.failures,
|
||||
{ pluginId: "<runtime-catalog>", reason: describeError(e) },
|
||||
],
|
||||
pending: [],
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
.then((unsubscribe) => {
|
||||
if (cancelled) unsubscribe();
|
||||
else unsubscribeFocus = unsubscribe;
|
||||
});
|
||||
|
||||
void gateways.system
|
||||
.onDomainEvent((event) => {
|
||||
if (event.type === "pluginInstalled" || event.type === "pluginReloaded") {
|
||||
void refreshRuntime(false);
|
||||
}
|
||||
})
|
||||
.then((unsubscribe) => {
|
||||
if (cancelled) unsubscribe();
|
||||
else unsubscribeDomainEvents = unsubscribe;
|
||||
});
|
||||
|
||||
void refreshRuntime(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
refreshRevision += 1;
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeDomainEvents?.();
|
||||
void disposeRegistry(activeRegistry);
|
||||
};
|
||||
// Gateways are a stable singleton for the app session (from `useGateways`);
|
||||
// re-running on every render would reload every plugin bundle.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* F2 — `PluginsPanel` (ticket #43, carnet §10 F2 acceptance criteria): install
|
||||
* from archive/directory with pre-install review, full-trust mention visible,
|
||||
* enable/disable/uninstall flows, and `restartRequired` rendered.
|
||||
* enable/disable/uninstall flows, and lifecycle feedback rendered.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
|
||||
@ -327,9 +327,9 @@ describe("PluginsPanel", () => {
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Installer" }));
|
||||
|
||||
// The installed plugin now shows in the list, restart required (carnet §1.4).
|
||||
// Install is live: the plugin is immediately active without a restart notice.
|
||||
expect(await screen.findByText("mock-plugin")).toBeTruthy();
|
||||
expect(screen.getByText("Redémarrage requis")).toBeTruthy();
|
||||
expect(screen.queryByText("Redémarrage requis")).toBeNull();
|
||||
expect(screen.getByText("Activé")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6).
|
||||
*
|
||||
* At UI bootstrap, the app calls {@link loadPlugins} once with the catalog
|
||||
* At UI bootstrap and after an install/reload event, the app calls
|
||||
* {@link loadPlugins} with the catalog
|
||||
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
|
||||
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set
|
||||
* used to build the public service facade. For each entry it dynamically imports
|
||||
@ -370,9 +371,8 @@ function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): Plug
|
||||
|
||||
/**
|
||||
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
|
||||
* Called once at app bootstrap (and never again in the same session — carnet
|
||||
* §1.3: disable/uninstall only masks contributions in-session, the actual
|
||||
* unload happens on next restart via a fresh `loadPlugins` call).
|
||||
* The provider swaps this fresh snapshot atomically at bootstrap and after a
|
||||
* supported plugin lifecycle event.
|
||||
*/
|
||||
export async function loadPlugins(
|
||||
catalogPlugins: PluginRuntimePlugin[],
|
||||
|
||||
Reference in New Issue
Block a user