/** * Ticket #82/#297 — the dedicated project `MCP` panel. * * Pins the UX contract from carnet #82 (Conception UX/F): an agent without an * override shows it inherits the project default with non-editable controls * until `Créer un override`; creating one prefills the effective allowlist; * toggling a tool updates the visible summary; saving calls the backend * command with the exact resulting allowlist; and an unsaved draft is * protected when the user switches target. */ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MockAgentGateway, MockPermissionGateway, MockPluginGateway, MockProfileGateway, MockUserSettingsGateway, } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import type { Gateways } from "@/ports"; import { McpPanel } from "./McpPanel"; import { I18nProvider } from "@/i18n"; const PROJECT_ID = "proj-mcp-permissions-test"; async function renderPanel() { const agent = new MockAgentGateway(); const permission = new MockPermissionGateway(); const created = await agent.createAgent(PROJECT_ID, { name: "DevFrontend", profileId: "p1", }); const gateways = { agent, permission, plugin: new MockPluginGateway(), profile: new MockProfileGateway(), userSettings: new MockUserSettingsGateway(), } as unknown as Gateways; render( , ); await waitFor(() => { expect(screen.getByText("DevFrontend")).toBeTruthy(); }); return { agent, permission, agentId: created.id }; } async function openMcpToolsTab() { await screen.findByRole("navigation", { name: "cibles des permissions MCP" }); } async function selectAgentTarget(name: string) { const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); fireEvent.click(within(nav).getByRole("button", { name: new RegExp(name) })); await waitFor(() => { expect(screen.getByRole("heading", { name: `MCP IdeA — ${name}` })).toBeTruthy(); }); } describe("McpPanel", () => { it("is exposed as a dedicated surface", async () => { await renderPanel(); await openMcpToolsTab(); expect(screen.getByRole("heading", { name: "MCP" })).toBeTruthy(); expect(screen.queryByRole("tablist", { name: "Permissions" })).toBeNull(); }); it("an agent without an override shows it inherits the project default, read-only", async () => { await renderPanel(); await openMcpToolsTab(); const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); const agentButton = within(nav).getByRole("button", { name: /DevFrontend/ }); expect(within(agentButton).getByText("Hérité")).toBeTruthy(); await selectAgentTarget("DevFrontend"); expect(screen.getByText("Hérite du défaut projet")).toBeTruthy(); expect(screen.getByRole("button", { name: "Créer un override" })).toBeTruthy(); // Read groups are collapsed by default in agent editing (carnet #82) — expand it. fireEvent.click(screen.getByRole("button", { name: /Lecture tickets/ })); // A canonical read-only tool is shown checked but disabled (inherited display). const readCheckbox = screen.getByRole("checkbox", { name: /idea_ticket_read$/, }) as HTMLInputElement; expect(readCheckbox.checked).toBe(true); expect(readCheckbox.disabled).toBe(true); // A write tool is shown unchecked (canonical read-only fallback denies it) and disabled. const writeCheckbox = screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/, }) as HTMLInputElement; expect(writeCheckbox.checked).toBe(false); expect(writeCheckbox.disabled).toBe(true); // No Save action is meaningfully available before creating an override. expect(screen.getByRole("button", { name: "Enregistrer" })).toHaveProperty("disabled", true); }); it("shows multi-agent delegation in the Délégation agents MCP group", async () => { await renderPanel(); await openMcpToolsTab(); const delegationGroup = screen.getByRole("button", { name: /Délégation agents/, }); expect(delegationGroup).toBeTruthy(); expect(screen.getByRole("checkbox", { name: /Solliciter plusieurs agents, idea_ask_agents$/, })).toBeTruthy(); }); it("surfaces native workspace tools as the Outils du projet group", async () => { await renderPanel(); await openMcpToolsTab(); expect(screen.getByRole("button", { name: /Outils du projet, 4 sur 4 autorisés/ })).toBeTruthy(); expect( screen.getByText("Permet à l’agent d’explorer, rechercher et lire les fichiers du projet."), ).toBeTruthy(); expect(screen.getByRole("checkbox", { name: /Cartographier le projet, idea_workspace_map$/, })).toBeTruthy(); expect(screen.getByRole("checkbox", { name: /Lister les éléments du projet, idea_workspace_list$/, })).toBeTruthy(); expect(screen.getByRole("checkbox", { name: /Lire un élément du projet, idea_workspace_read$/, })).toBeTruthy(); expect(screen.getByRole("checkbox", { name: /Rechercher dans le projet, idea_workspace_search$/, })).toBeTruthy(); }); it("shows cross-project tools in their UX group and catalogued unmapped tools in Autres", async () => { const agent = new MockAgentGateway(); const permission = new MockPermissionGateway(); permission.setMcpToolCatalogue({ readOnlyTools: ["idea_project_list", "idea_future_read"], writeActionTools: ["idea_project_ask_agent"], }); const gateways = { agent, permission, plugin: new MockPluginGateway(), profile: new MockProfileGateway(), userSettings: new MockUserSettingsGateway(), } as unknown as Gateways; render( , ); await openMcpToolsTab(); expect(screen.getByRole("button", { name: /IdeA inter-projets/ })).toBeTruthy(); expect( screen.getByRole("checkbox", { name: /Demander à un agent d'un autre projet, idea_project_ask_agent$/, }), ).toBeTruthy(); expect(screen.getByRole("button", { name: /Autres/ })).toBeTruthy(); const fallback = screen.getByRole("checkbox", { name: /idea_future_read, idea_future_read$/, }) as HTMLInputElement; expect(fallback.disabled).toBe(false); }); it("creating an override prefills the draft with the current effective allowlist", async () => { await renderPanel(); await openMcpToolsTab(); await selectAgentTarget("DevFrontend"); fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); expect(screen.getByText("Override personnalisé")).toBeTruthy(); // Read groups are collapsed by default in agent editing (carnet #82) — expand it. fireEvent.click(screen.getByRole("button", { name: /Lecture tickets/ })); // Prefilled from the effective (inherited) allowlist: canonical read-only // tools checked and now editable. const readCheckbox = screen.getByRole("checkbox", { name: /idea_ticket_read$/, }) as HTMLInputElement; expect(readCheckbox.checked).toBe(true); expect(readCheckbox.disabled).toBe(false); const writeCheckbox = screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/, }) as HTMLInputElement; expect(writeCheckbox.checked).toBe(false); // No edit yet: nothing to save. expect(screen.getByRole("button", { name: "Enregistrer" })).toHaveProperty("disabled", true); }); it("checking a write tool updates the effective summary and unsaved-changes flag", async () => { await renderPanel(); await openMcpToolsTab(); await selectAgentTarget("DevFrontend"); fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); expect(screen.getByText(/20 lectures autorisées · 0 écritures autorisées/)).toBeTruthy(); const writeCheckbox = screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/, }); fireEvent.click(writeCheckbox); expect(screen.getByText(/20 lectures autorisées · 1 écriture autorisée/)).toBeTruthy(); expect(screen.getByText("Modifications non enregistrées")).toBeTruthy(); expect(screen.getByText("Ajouté")).toBeTruthy(); }); it("saving calls updateAgentMcpToolPermissions with the exact resulting allowlist", async () => { const { permission, agentId } = await renderPanel(); const spy = vi.spyOn(permission, "updateAgentMcpToolPermissions"); await openMcpToolsTab(); await selectAgentTarget("DevFrontend"); fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ })); fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); const [calledProjectId, calledAgentId, calledPolicy] = spy.mock.calls[0]!; expect(calledProjectId).toBe(PROJECT_ID); expect(calledAgentId).toBe(agentId); expect(calledPolicy?.allowedTools).toEqual( expect.arrayContaining([ "idea_list_agents", "idea_context_read", "idea_memory_read", "idea_skill_read", "idea_workstate_read", "idea_ticket_read", "idea_ticket_list", "idea_ticket_read_carnet", "idea_sprint_list", "idea_ticket_update_carnet", ]), ); expect(calledPolicy?.allowedTools).toHaveLength(21); // Persisted: the left-column badge flips to Override, and Réinitialiser appears. const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); const agentButton = within(nav).getByRole("button", { name: /DevFrontend/ }); expect(within(agentButton).getByText("Override")).toBeTruthy(); expect(screen.getByRole("button", { name: "Réinitialiser l'override" })).toBeTruthy(); }); describe("draft protection on target change", () => { const originalConfirm = window.confirm; beforeEach(() => { window.confirm = vi.fn(); }); afterEach(() => { window.confirm = originalConfirm; }); it("asks for confirmation, and keeps the draft when the user cancels", async () => { await renderPanel(); await openMcpToolsTab(); await selectAgentTarget("DevFrontend"); fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ })); (window.confirm as ReturnType).mockReturnValue(false); const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ })); expect(window.confirm).toHaveBeenCalled(); // Still on the agent editor, with the unsaved edit intact. expect( screen.getByRole("heading", { name: "MCP IdeA — DevFrontend" }), ).toBeTruthy(); expect( (screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }) as HTMLInputElement) .checked, ).toBe(true); }); it("discards the draft and switches target when the user confirms", async () => { await renderPanel(); await openMcpToolsTab(); await selectAgentTarget("DevFrontend"); fireEvent.click(screen.getByRole("button", { name: "Créer un override" })); fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ })); (window.confirm as ReturnType).mockReturnValue(true); const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ })); expect(window.confirm).toHaveBeenCalled(); expect( screen.getByRole("heading", { name: "Tools MCP IdeA — Défaut projet" }), ).toBeTruthy(); }); it("does not prompt when switching target with no unsaved changes", async () => { await renderPanel(); await openMcpToolsTab(); await selectAgentTarget("DevFrontend"); const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" }); fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ })); expect(window.confirm).not.toHaveBeenCalled(); expect( screen.getByRole("heading", { name: "Tools MCP IdeA — Défaut projet" }), ).toBeTruthy(); }); }); });