feat(frontend): système de plugins — runtime, menus, layouts custom (#43)
Lots F1-F4 : runtime de chargement/registre plugin, extension des menus existants, panneau de gestion des plugins, types de layout custom (sélecteur, fallback, cellule dédiée) branchés sur le port plugin. Suite npm typecheck/test verte (947/947). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
169
frontend/src/features/projects/pluginGitRepository.test.tsx
Normal file
169
frontend/src/features/projects/pluginGitRepository.test.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* #43, F3 — `ProjectsView` wires the `gitRepository` `when` signal to a real
|
||||
* check (`GitGateway.branches`), not a hardcoded `false` (Architect-mandated,
|
||||
* blocking for #43 closure). This exercises a plugin menu item declared
|
||||
* `when: "projectOpen && gitRepository"` end-to-end: disabled for a project
|
||||
* whose git check fails (not a repo), enabled for one whose check succeeds.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockDesktopServerGateway,
|
||||
MockProfileGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTemplateGateway,
|
||||
MockWindowGateway,
|
||||
MockWorkStateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { GatewayError, GitBranches, GitCommit, GitFileStatus, GraphCommit } from "@/domain";
|
||||
import type { Gateways, GitGateway } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
PluginMenuRegistry,
|
||||
PluginRuntimeRegistry,
|
||||
type LoadedPlugin,
|
||||
} from "@/plugins/runtime";
|
||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||
import { ProjectsView } from "./ProjectsView";
|
||||
|
||||
/** Minimal `GitGateway` double whose `branches()` always rejects — "not a repo". */
|
||||
class NonRepoGitGateway implements GitGateway {
|
||||
status(): Promise<GitFileStatus[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
stage(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
unstage(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
commit(): Promise<GitCommit> {
|
||||
return Promise.reject({ code: "NOT_IMPLEMENTED", message: "n/a" } satisfies GatewayError);
|
||||
}
|
||||
branches(): Promise<GitBranches> {
|
||||
const err: GatewayError = { code: "NOT_A_REPOSITORY", message: "not a git repository" };
|
||||
return Promise.reject(err);
|
||||
}
|
||||
checkout(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
log(): Promise<GitCommit[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
init(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
graph(): Promise<GraphCommit[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal `GitGateway` double whose `branches()` always resolves — a real repo. */
|
||||
class RealRepoGitGateway extends NonRepoGitGateway {
|
||||
override branches(): Promise<GitBranches> {
|
||||
return Promise.resolve({ branches: ["main"], current: "main" });
|
||||
}
|
||||
}
|
||||
|
||||
function gitAwarePlugin(): LoadedPlugin {
|
||||
const contributes = {
|
||||
menus: [],
|
||||
menuItems: [
|
||||
{
|
||||
id: "dev.acme.gitgraph.open",
|
||||
targetMenuId: "panels" as const,
|
||||
label: "Ouvrir le graphe Git",
|
||||
command: "dev.acme.gitgraph.open",
|
||||
when: "projectOpen && gitRepository",
|
||||
},
|
||||
],
|
||||
layouts: [],
|
||||
mcpServers: [],
|
||||
};
|
||||
return {
|
||||
pluginId: "dev.acme.gitgraph",
|
||||
displayName: "Git Graph",
|
||||
contributes,
|
||||
commands: new PluginCommandRegistry(
|
||||
"dev.acme.gitgraph",
|
||||
new Set(["dev.acme.gitgraph.open"]),
|
||||
),
|
||||
layouts: new PluginLayoutRegistry("dev.acme.gitgraph", new Set()),
|
||||
menu: new PluginMenuRegistry("dev.acme.gitgraph"),
|
||||
dispose: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function renderWithPlugin(git: GitGateway) {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const gateways = {
|
||||
system: new MockSystemGateway(),
|
||||
project: new MockProjectGateway(),
|
||||
agent: agentGateway,
|
||||
profile: new MockProfileGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
git,
|
||||
workState: new MockWorkStateGateway(),
|
||||
window: new MockWindowGateway(),
|
||||
desktopServer: new MockDesktopServerGateway(),
|
||||
} as unknown as Gateways;
|
||||
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
registry.add(gitAwarePlugin());
|
||||
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
|
||||
<ProjectsView />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForIdle() {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Refresh" }) as HTMLButtonElement).disabled,
|
||||
).toBe(false),
|
||||
);
|
||||
}
|
||||
|
||||
async function createProject(name: string, root: string) {
|
||||
await waitForIdle();
|
||||
fireEvent.change(screen.getByLabelText("project name"), { target: { value: name } });
|
||||
fireEvent.change(screen.getByLabelText("project root"), { target: { value: root } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create project" }));
|
||||
}
|
||||
|
||||
function panelsMenuItem(): HTMLButtonElement {
|
||||
return screen.getByRole("button", { name: /Ouvrir le graphe Git/ }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe("ProjectsView — plugin `gitRepository` when-context wiring", () => {
|
||||
it("disables the item when the project's git check fails (not a repo)", async () => {
|
||||
renderWithPlugin(new NonRepoGitGateway());
|
||||
await createProject("alpha", "/home/me/non-repo");
|
||||
await screen.findByRole("tab");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||
await waitFor(() => {
|
||||
expect(panelsMenuItem().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables the item once the project's git check succeeds (real repo)", async () => {
|
||||
renderWithPlugin(new RealRepoGitGateway());
|
||||
await createProject("beta", "/home/me/real-repo");
|
||||
await screen.findByRole("tab");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Panneaux" }));
|
||||
await waitFor(() => {
|
||||
expect(panelsMenuItem().disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user