feat(application,backend,infrastructure,app-tauri,frontend): contributions UI/runtime des plugins limitées aux projets liés — #321 (QA verte)
Les contributions runtime des plugins (menus, slash commands, layouts, surfaces) ne sont exposées que dans les projets liés au plugin. - Définition du lien: ProjectPluginSettingsStore par racine projet (domaine) — une contribution runtime-active n'entre dans le catalogue que si elle est explicitement activée pour le projet courant; hors projet focalisé, catalogue vide - Application: ListPluginRuntimeContributions.execute prend un project_id optionnel, charge projet + settings et filtre le registre par activation projet; tests fake store/settings (catalogue vide sans projet, activation, layout, slash commands) - Backend/app-tauri: plugin_list_runtime_contributions et slash commands list/execute acceptent projectId optionnel (DTO camelCase, défaut serde), BackendCore recâblé avec store_port et project_plugin_settings_store; test DTO de scope optionnel - Frontend: PluginGateway.listRuntimeContributions(projectId) (ports, tauri, http/unsupported, mock alignés); PluginRuntimeProvider re-scope le catalogue au changement de focus projet (vidage du registre précédent, disposition des bundles, requêtes obsolètes ignorées par projectId/révision); PluginLayoutCellView ignore les références de layout devenues hors périmètre via onChooseAnotherLayout et ne rend rien pendant le rechargement (aucune fuite inter-projets) - Tests: plugin_install_load (infrastructure), dto slash commands, plugin.test, PluginRuntimeProvider.test, PluginLayoutCellView.test QA verte sur feature/321-plugin-contributions-linked-projects. Aucun push.
This commit is contained in:
@ -1,9 +1,9 @@
|
||||
/**
|
||||
* F4 — `PluginLayoutCellView` (ticket #43, carnet §10 F4 acceptance criteria:
|
||||
* "rendu composant mock, state roundtrip, fallback sans mutation du layout").
|
||||
* "rendu composant mock, state roundtrip, isolation des surfaces indisponibles").
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { CustomPluginLayoutCell } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
@ -68,7 +68,11 @@ function stubPlugin(component = MockLayoutComponent): LoadedPlugin {
|
||||
|
||||
function renderCell(
|
||||
registry: PluginRuntimeRegistry,
|
||||
props: Partial<{ cell: CustomPluginLayoutCell; onStateChange: (s: unknown) => void }> = {},
|
||||
props: Partial<{
|
||||
cell: CustomPluginLayoutCell;
|
||||
onStateChange: (s: unknown) => void;
|
||||
onChooseAnotherLayout: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const gateways: Gateways = createMockGateways();
|
||||
return render(
|
||||
@ -79,7 +83,7 @@ function renderCell(
|
||||
cell={props.cell ?? cell()}
|
||||
onStateChange={props.onStateChange ?? (() => {})}
|
||||
onOpenPlugins={() => {}}
|
||||
onChooseAnotherLayout={() => {}}
|
||||
onChooseAnotherLayout={props.onChooseAnotherLayout ?? (() => {})}
|
||||
/>
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
@ -111,13 +115,14 @@ describe("PluginLayoutCellView", () => {
|
||||
expect(lastState).toEqual({ commits: 4 });
|
||||
});
|
||||
|
||||
it("renders the non-destructive fallback when the provider is not loaded, without mutating the cell", () => {
|
||||
it("ignores an unavailable layout reference without rendering a disabled surface", async () => {
|
||||
const registry = new PluginRuntimeRegistry(); // empty — provider not loaded
|
||||
const testCell = cell();
|
||||
renderCell(registry, { cell: testCell });
|
||||
const onChooseAnotherLayout = vi.fn();
|
||||
renderCell(registry, { cell: testCell, onChooseAnotherLayout });
|
||||
|
||||
expect(screen.getByText("Layout indisponible")).toBeTruthy();
|
||||
// The cell object itself is untouched — the domain identity/state survive.
|
||||
expect(screen.queryByText("Layout indisponible")).toBeNull();
|
||||
await waitFor(() => expect(onChooseAnotherLayout).toHaveBeenCalledOnce());
|
||||
expect(testCell).toEqual(cell());
|
||||
});
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
* calls from inside a plugin component).
|
||||
*/
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Component, useEffect, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain";
|
||||
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||
@ -66,23 +66,21 @@ export function PluginLayoutCellView({
|
||||
onOpenPlugins,
|
||||
onChooseAnotherLayout,
|
||||
}: PluginLayoutCellViewProps) {
|
||||
const { registry } = usePluginRuntime();
|
||||
const { registry, loading } = usePluginRuntime();
|
||||
const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins);
|
||||
const providerDisplayName =
|
||||
registry.get(cell.pluginId)?.displayName ??
|
||||
installedPlugins?.find((p) => p.id === cell.pluginId)?.displayName;
|
||||
|
||||
if (availability !== "available") {
|
||||
return (
|
||||
<PluginLayoutFallback
|
||||
cell={cell}
|
||||
availability={availability}
|
||||
providerDisplayName={providerDisplayName}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
onChooseAnotherLayout={onChooseAnotherLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!loading && availability !== "available") onChooseAnotherLayout();
|
||||
}, [availability, loading, onChooseAnotherLayout]);
|
||||
|
||||
// A layout contribution absent from the current project's runtime catalog
|
||||
// is not a disabled surface: its stale reference is ignored/closed by the
|
||||
// owner callback. While the project-scoped catalog is changing, render
|
||||
// nothing so contributions from the previous project cannot leak through.
|
||||
if (loading || availability !== "available") return null;
|
||||
|
||||
const Component = registry.layoutComponent(cell.pluginId, cell.layoutType)!;
|
||||
const fallback = (
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createMockGateways, MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
|
||||
import {
|
||||
createMockGateways,
|
||||
MockFocusedProjectGateway,
|
||||
MockPluginGateway,
|
||||
MockSystemGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type {
|
||||
CustomPluginLayoutCell,
|
||||
@ -33,10 +38,26 @@ function runtimePlugin(overrides: Partial<PluginRuntimePlugin> = {}): PluginRunt
|
||||
class MutableRuntimeCatalogGateway extends MockPluginGateway {
|
||||
calls = 0;
|
||||
catalog: PluginRuntimeContributionCatalog = { plugins: [] };
|
||||
catalogs = new Map<string, PluginRuntimeContributionCatalog>();
|
||||
projectIds: Array<string | undefined> = [];
|
||||
|
||||
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||
async listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog> {
|
||||
this.calls += 1;
|
||||
return this.catalog;
|
||||
this.projectIds.push(projectId);
|
||||
return (projectId ? this.catalogs.get(projectId) : undefined) ?? this.catalog;
|
||||
}
|
||||
}
|
||||
|
||||
class DeferredRuntimeCatalogGateway extends MockPluginGateway {
|
||||
requests: Array<{
|
||||
projectId: string | undefined;
|
||||
resolve: (catalog: PluginRuntimeContributionCatalog) => void;
|
||||
}> = [];
|
||||
|
||||
async listRuntimeContributions(projectId?: string): Promise<PluginRuntimeContributionCatalog> {
|
||||
return new Promise((resolve) => {
|
||||
this.requests.push({ projectId, resolve });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +82,9 @@ 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 };
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
await focusedProject.setFocusedProject({ id: "project-1", name: "Alpha", root: "/alpha" });
|
||||
const gateways = { ...createMockGateways(), plugin, system, focusedProject };
|
||||
let mounts = 0;
|
||||
const onMount = () => {
|
||||
mounts += 1;
|
||||
@ -108,12 +131,16 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
||||
expect(screen.getByTestId("workspace-state").textContent).toBe("preserved");
|
||||
expect(mounts).toBe(1);
|
||||
expect(plugin.calls).toBe(3);
|
||||
expect(plugin.projectIds).toEqual(["project-1", "project-1", "project-1"]);
|
||||
});
|
||||
|
||||
it("keeps a custom plugin layout cell mounted as a fallback when its contribution disappears", async () => {
|
||||
it("ignores a custom plugin layout ref when its contribution disappears", async () => {
|
||||
const plugin = new MutableRuntimeCatalogGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const gateways = { ...createMockGateways(), plugin, system };
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
await focusedProject.setFocusedProject({ id: "project-1", name: "Alpha", root: "/alpha" });
|
||||
const gateways = { ...createMockGateways(), plugin, system, focusedProject };
|
||||
const onChooseAnotherLayout = vi.fn();
|
||||
const testCell: CustomPluginLayoutCell = {
|
||||
id: "plugin-cell-1",
|
||||
pluginId: "dev.acme.refreshable",
|
||||
@ -155,7 +182,7 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
||||
cell={testCell}
|
||||
onStateChange={() => {}}
|
||||
onOpenPlugins={() => {}}
|
||||
onChooseAnotherLayout={() => {}}
|
||||
onChooseAnotherLayout={onChooseAnotherLayout}
|
||||
/>
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
@ -171,7 +198,8 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
||||
changed: true,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Layout indisponible")).toBeTruthy();
|
||||
await waitFor(() => expect(onChooseAnotherLayout).toHaveBeenCalledOnce());
|
||||
expect(screen.queryByText("Layout indisponible")).toBeNull();
|
||||
expect(testCell).toEqual({
|
||||
id: "plugin-cell-1",
|
||||
pluginId: "dev.acme.refreshable",
|
||||
@ -180,4 +208,71 @@ describe("PluginRuntimeProvider soft refresh (#279)", () => {
|
||||
});
|
||||
await waitFor(() => expect(plugin.calls).toBe(2));
|
||||
});
|
||||
|
||||
it("replaces the runtime registry when the focused project changes", async () => {
|
||||
const plugin = new MutableRuntimeCatalogGateway();
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
await focusedProject.setFocusedProject({ id: "project-a", name: "Alpha", root: "/alpha" });
|
||||
plugin.catalogs.set("project-a", {
|
||||
plugins: [runtimePlugin({ displayName: "Plugin Alpha" })],
|
||||
});
|
||||
plugin.catalogs.set("project-b", { plugins: [] });
|
||||
const gateways = { ...createMockGateways(), plugin, focusedProject };
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider>
|
||||
<RuntimeProbe onMount={() => {}} />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Plugin Alpha")).toBeTruthy();
|
||||
await focusedProject.setFocusedProject({ id: "project-b", name: "Beta", root: "/beta" });
|
||||
|
||||
expect(screen.queryByText("Plugin Alpha")).toBeNull();
|
||||
expect(await screen.findByText("none")).toBeTruthy();
|
||||
expect(plugin.projectIds).toEqual(["project-a", "project-b"]);
|
||||
});
|
||||
|
||||
it("ignores a stale runtime catalog response after the focused project changes", async () => {
|
||||
const plugin = new DeferredRuntimeCatalogGateway();
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
await focusedProject.setFocusedProject({ id: "project-a", name: "Alpha", root: "/alpha" });
|
||||
const gateways = { ...createMockGateways(), plugin, focusedProject };
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider>
|
||||
<RuntimeProbe onMount={() => {}} />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(plugin.requests).toHaveLength(1));
|
||||
expect(plugin.requests[0].projectId).toBe("project-a");
|
||||
|
||||
await focusedProject.setFocusedProject({ id: "project-b", name: "Beta", root: "/beta" });
|
||||
|
||||
await waitFor(() => expect(plugin.requests).toHaveLength(2));
|
||||
expect(plugin.requests[1].projectId).toBe("project-b");
|
||||
|
||||
await act(async () => {
|
||||
plugin.requests[1].resolve({
|
||||
plugins: [runtimePlugin({ displayName: "Plugin Beta" })],
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText("Plugin Beta")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
plugin.requests[0].resolve({
|
||||
plugins: [runtimePlugin({ displayName: "Plugin Alpha" })],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("runtime-plugin").textContent).toBe("Plugin Beta"),
|
||||
);
|
||||
expect(screen.queryByText("Plugin Alpha")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* `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
|
||||
* It fetches the runtime catalog (already filtered to plugins linked to the
|
||||
* focused project and runtime-active 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).
|
||||
*
|
||||
@ -19,7 +19,7 @@ import {
|
||||
type PluginLoadPending,
|
||||
} from "@/plugins/runtime";
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { PluginRuntimePlugin, Unsubscribe } from "@/domain";
|
||||
import type { Unsubscribe } from "@/domain";
|
||||
|
||||
export interface PluginRuntimeContextValue {
|
||||
registry: PluginRuntimeRegistry;
|
||||
@ -89,10 +89,11 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
pluginConfig: gateways.pluginConfig,
|
||||
pluginStorage: gateways.pluginStorage,
|
||||
};
|
||||
let pendingProjectPlugins: PluginRuntimePlugin[] = [];
|
||||
let unsubscribeFocus: Unsubscribe | undefined;
|
||||
let unsubscribeDomainEvents: Unsubscribe | undefined;
|
||||
let activeRegistry = value.registry;
|
||||
let currentProjectId: string | undefined;
|
||||
let focusRevision = 0;
|
||||
|
||||
async function disposeRegistry(registry: PluginRuntimeRegistry): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
@ -100,18 +101,32 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
);
|
||||
}
|
||||
|
||||
function clearRuntimeForProjectChange(): void {
|
||||
const previousRegistry = activeRegistry;
|
||||
activeRegistry = new PluginRuntimeRegistry();
|
||||
setValue({
|
||||
registry: activeRegistry,
|
||||
failures: [],
|
||||
pending: [],
|
||||
loading: true,
|
||||
});
|
||||
if (previousRegistry !== activeRegistry) void disposeRegistry(previousRegistry);
|
||||
}
|
||||
|
||||
async function refreshRuntime(initial: boolean): Promise<void> {
|
||||
const revision = ++refreshRevision;
|
||||
const projectId = currentProjectId;
|
||||
try {
|
||||
const catalog = await gateways.plugin.listRuntimeContributions();
|
||||
const catalog = await gateways.plugin.listRuntimeContributions(projectId);
|
||||
const result = await loadPlugins(catalog.plugins, pluginGateways);
|
||||
if (cancelled || revision !== refreshRevision) {
|
||||
if (
|
||||
cancelled ||
|
||||
revision !== refreshRevision ||
|
||||
projectId !== currentProjectId
|
||||
) {
|
||||
await disposeRegistry(result.registry);
|
||||
return;
|
||||
}
|
||||
pendingProjectPlugins = catalog.plugins.filter((entry) =>
|
||||
result.pending.some((pending) => pending.pluginId === entry.id),
|
||||
);
|
||||
const previousRegistry = activeRegistry;
|
||||
activeRegistry = result.registry;
|
||||
setValue({
|
||||
@ -133,16 +148,35 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
}
|
||||
}
|
||||
|
||||
function focusProject(projectId: string | undefined, initial = false): void {
|
||||
if (projectId === currentProjectId && refreshRevision > 0) return;
|
||||
currentProjectId = projectId;
|
||||
clearRuntimeForProjectChange();
|
||||
void refreshRuntime(initial);
|
||||
}
|
||||
|
||||
void gateways.focusedProject
|
||||
.onFocusedProjectChanged((project) => {
|
||||
if (!project || pendingProjectPlugins.length === 0) return;
|
||||
void refreshRuntime(false);
|
||||
focusRevision += 1;
|
||||
focusProject(project?.id);
|
||||
})
|
||||
.then((unsubscribe) => {
|
||||
if (cancelled) unsubscribe();
|
||||
else unsubscribeFocus = unsubscribe;
|
||||
});
|
||||
|
||||
const initialFocusRevision = focusRevision;
|
||||
void gateways.focusedProject
|
||||
.getFocusedProject()
|
||||
.then((project) => {
|
||||
if (!cancelled && focusRevision === initialFocusRevision) {
|
||||
focusProject(project?.id, true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && focusRevision === initialFocusRevision) focusProject(undefined, true);
|
||||
});
|
||||
|
||||
void gateways.system
|
||||
.onDomainEvent((event) => {
|
||||
if (event.type === "pluginInstalled" || event.type === "pluginReloaded") {
|
||||
@ -154,7 +188,6 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
else unsubscribeDomainEvents = unsubscribe;
|
||||
});
|
||||
|
||||
void refreshRuntime(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
refreshRevision += 1;
|
||||
|
||||
Reference in New Issue
Block a user