Le chargement de l'archive hello-plugin (build/hello-plugin-0.1.0.zip) vidait la fenêtre principale : une contribution plugin fautive remontait jusqu'au rendu global au lieu de rester locale à la cellule. Ajoute un boundary local dans PluginLayoutCellView/PluginLayoutSelectorSection et durcit menus.ts/loader.ts/registry.ts contre les entrées de menu ou contributions malformées. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
/**
|
|
* F4 — `PluginLayoutCellView` (ticket #43, carnet §10 F4 acceptance criteria:
|
|
* "rendu composant mock, state roundtrip, fallback sans mutation du layout").
|
|
*/
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
|
|
import type { CustomPluginLayoutCell } from "@/domain";
|
|
import type { Gateways } from "@/ports";
|
|
import { DIProvider } from "@/app/di";
|
|
import { createMockGateways } from "@/adapters/mock";
|
|
import {
|
|
PluginCommandRegistry,
|
|
PluginLayoutRegistry,
|
|
PluginMenuRegistry,
|
|
PluginRuntimeRegistry,
|
|
type LoadedPlugin,
|
|
type PluginLayoutProps,
|
|
} from "@/plugins/runtime";
|
|
import { PluginRuntimeProvider } from "./PluginRuntimeProvider";
|
|
import { PluginLayoutCellView } from "./PluginLayoutCellView";
|
|
|
|
function cell(overrides: Partial<CustomPluginLayoutCell> = {}): CustomPluginLayoutCell {
|
|
return {
|
|
id: "leaf-1",
|
|
pluginId: "dev.acme.gitgraph",
|
|
layoutType: "dev.acme.gitgraph.layout",
|
|
state: { commits: 3 },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function MockLayoutComponent(props: PluginLayoutProps) {
|
|
return (
|
|
<div>
|
|
<p data-testid="state">{JSON.stringify(props.state)}</p>
|
|
<button onClick={() => props.setState({ commits: 4 })}>bump</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ThrowingLayoutComponent(_props: PluginLayoutProps): JSX.Element {
|
|
throw new Error("plugin layout render failed");
|
|
}
|
|
|
|
function stubPlugin(component = MockLayoutComponent): LoadedPlugin {
|
|
const contributes = {
|
|
menus: [],
|
|
menuItems: [],
|
|
layouts: [{ type: "dev.acme.gitgraph.layout", label: "Git Graph", component: "GitGraphLayout" }],
|
|
mcpServers: [],
|
|
};
|
|
const layouts = new PluginLayoutRegistry("dev.acme.gitgraph", new Set(["dev.acme.gitgraph.layout"]));
|
|
layouts.register({ type: "dev.acme.gitgraph.layout", component });
|
|
return {
|
|
pluginId: "dev.acme.gitgraph",
|
|
displayName: "Git Graph",
|
|
contributes,
|
|
commands: new PluginCommandRegistry("dev.acme.gitgraph", new Set()),
|
|
layouts,
|
|
menu: new PluginMenuRegistry("dev.acme.gitgraph"),
|
|
dispose: async () => {},
|
|
};
|
|
}
|
|
|
|
function renderCell(
|
|
registry: PluginRuntimeRegistry,
|
|
props: Partial<{ cell: CustomPluginLayoutCell; onStateChange: (s: unknown) => void }> = {},
|
|
) {
|
|
const gateways: Gateways = createMockGateways();
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
|
|
<PluginLayoutCellView
|
|
projectId="proj-1"
|
|
cell={props.cell ?? cell()}
|
|
onStateChange={props.onStateChange ?? (() => {})}
|
|
onOpenPlugins={() => {}}
|
|
onChooseAnotherLayout={() => {}}
|
|
/>
|
|
</PluginRuntimeProvider>
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
describe("PluginLayoutCellView", () => {
|
|
it("renders the mock registered component when the provider is loaded", () => {
|
|
const registry = new PluginRuntimeRegistry();
|
|
registry.add(stubPlugin());
|
|
renderCell(registry);
|
|
expect(screen.getByTestId("state").textContent).toBe(JSON.stringify({ commits: 3 }));
|
|
});
|
|
|
|
it("round-trips state through setState → onStateChange", () => {
|
|
const registry = new PluginRuntimeRegistry();
|
|
registry.add(stubPlugin());
|
|
let lastState: unknown = null;
|
|
renderCell(registry, {
|
|
onStateChange: (s) => {
|
|
lastState = s;
|
|
},
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "bump" }));
|
|
expect(lastState).toEqual({ commits: 4 });
|
|
});
|
|
|
|
it("renders the non-destructive fallback when the provider is not loaded, without mutating the cell", () => {
|
|
const registry = new PluginRuntimeRegistry(); // empty — provider not loaded
|
|
const testCell = cell();
|
|
renderCell(registry, { cell: testCell });
|
|
|
|
expect(screen.getByText("Layout indisponible")).toBeTruthy();
|
|
// The cell object itself is untouched — the domain identity/state survive.
|
|
expect(testCell).toEqual(cell());
|
|
});
|
|
|
|
it("isolates a plugin layout render failure to the plugin cell fallback", () => {
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
try {
|
|
const registry = new PluginRuntimeRegistry();
|
|
registry.add(stubPlugin(ThrowingLayoutComponent));
|
|
renderCell(registry);
|
|
|
|
expect(screen.getByText("Layout indisponible")).toBeTruthy();
|
|
expect(screen.queryByTestId("state")).toBeNull();
|
|
} finally {
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
});
|