feat(FN-1493): add directory browsing UX to plugin install flow

- Add DirectoryPicker component for browsing filesystem to select plugin source
- Add dist-folder and package-root install-source resolution in backend
- Wire frontend installPlugin to mode-discriminated endpoint (/plugins/install-local)
- Add comprehensive regression tests for browse-driven plugin install workflow
- Update PluginManager UI with directory picker integration and CSS styles
This commit is contained in:
gsxdsm
2026-04-12 18:10:39 -07:00
parent 9d2f61501b
commit 717124fc9e
10 changed files with 1318 additions and 119 deletions

View File

@@ -0,0 +1,301 @@
/**
* Regression tests for the browse-driven plugin install workflow.
*
* Verifies that:
* 1. DirectoryPicker selection updates the install source field
* 2. Install sends the expected { path } payload
* 3. Negative paths: empty path, install failure
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, within, fireEvent, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { PluginInstallation } from "@fusion/core";
// ── Mock API ──────────────────────────────────────────────────────
vi.mock("../../api", () => ({
fetchPlugins: vi.fn(() => Promise.resolve([])),
installPlugin: vi.fn(() =>
Promise.resolve({
id: "browsed-plugin",
name: "Browsed Plugin",
version: "0.1.0",
state: "installed" as const,
enabled: true,
settings: {},
settingsSchema: {},
}),
),
enablePlugin: vi.fn(() => Promise.resolve({})),
disablePlugin: vi.fn(() => Promise.resolve({})),
uninstallPlugin: vi.fn(() => Promise.resolve()),
fetchPluginSettings: vi.fn(() => Promise.resolve({})),
updatePluginSettings: vi.fn(() => Promise.resolve({})),
reloadPlugin: vi.fn(() => Promise.resolve({})),
browseDirectory: vi.fn(() =>
Promise.resolve({
currentPath: "/home/user/plugins/my-plugin",
parentPath: "/home/user/plugins",
entries: [
{ name: "dist", path: "/home/user/plugins/my-plugin/dist", hasChildren: true },
{ name: "src", path: "/home/user/plugins/my-plugin/src", hasChildren: true },
],
}),
),
}));
import { PluginManager } from "../PluginManager";
import {
fetchPlugins,
installPlugin,
browseDirectory,
} from "../../api";
const addToast = vi.fn();
// ── Shared setup ──────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
// Stub EventSource globally
const esInstance = {
url: "",
readyState: 1,
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
onerror: null,
onopen: null,
onmessage: null,
};
const MockES = vi.fn(() => esInstance) as unknown as typeof EventSource;
MockES.CONNECTING = 0;
MockES.OPEN = 1;
MockES.CLOSED = 2;
vi.stubGlobal("EventSource", MockES);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ── Helpers ───────────────────────────────────────────────────────
/** Open the install form by clicking the header Install button. */
async function openInstallForm() {
const installBtn = screen.getByRole("button", { name: /^Install$/ });
await userEvent.click(installBtn);
}
/** Return the DirectoryPicker input inside the install form. */
function getPathInput(): HTMLInputElement {
return screen.getByPlaceholderText(
"Absolute path to plugin directory or dist folder",
) as HTMLInputElement;
}
// ══════════════════════════════════════════════════════════════════
describe("PluginManager browse-driven install workflow", () => {
it("opens DirectoryPicker browser and selects a directory to set install path", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Click "Browse" to open the directory browser
const browseBtn = screen.getByRole("button", {
name: /Browse directories/i,
});
await userEvent.click(browseBtn);
// browseDirectory should have been called
await waitFor(() => expect(browseDirectory).toHaveBeenCalled());
// The browser shows the "Select" button
const selectBtn = screen.getByRole("button", { name: /^Select$/i });
// Clicking Select should copy the currentPath into the input
await userEvent.click(selectBtn);
// The input value should now match the browsed directory
expect(getPathInput().value).toBe("/home/user/plugins/my-plugin");
});
it("sends { path } payload matching the browsed path on install", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Simulate browse-and-select by typing a path (DirectoryPicker onChange)
const input = getPathInput();
await userEvent.type(input, "/home/user/plugins/my-plugin");
// Click Install Plugin
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith(
{ path: "/home/user/plugins/my-plugin" },
undefined,
);
});
});
it("sends { path } with dist subfolder when user navigates into dist", async () => {
// Override browseDirectory to return a dist-level path
vi.mocked(browseDirectory).mockResolvedValueOnce({
currentPath: "/home/user/plugins/my-plugin/dist",
parentPath: "/home/user/plugins/my-plugin",
entries: [],
});
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Directly type a dist path (mimicking what Select would do)
const input = getPathInput();
await userEvent.type(input, "/home/user/plugins/my-plugin/dist");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith(
{ path: "/home/user/plugins/my-plugin/dist" },
undefined,
);
});
});
it("passes projectId when provided", async () => {
render(<PluginManager addToast={addToast} projectId="proj-xyz" />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalledWith("proj-xyz"));
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/plugins/example");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith(
{ path: "/plugins/example" },
"proj-xyz",
);
});
});
// ── Negative paths ────────────────────────────────────────────
it("shows error toast when install path is empty", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Install Plugin button should be disabled with empty input
const formContainer = getPathInput().closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
expect(installBtn).toBeDisabled();
// installPlugin should NOT have been called
expect(installPlugin).not.toHaveBeenCalled();
});
it("shows error toast when installPlugin rejects", async () => {
vi.mocked(installPlugin).mockRejectedValueOnce(
new Error("manifest.json not found"),
);
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/invalid/path");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("manifest.json not found"),
"error",
);
});
});
it("resets install path and hides form on successful install", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/path/to/plugin");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
"Plugin installed successfully",
"success",
);
});
// Install form should be gone
expect(
screen.queryByPlaceholderText(
"Absolute path to plugin directory or dist folder",
),
).toBeNull();
});
it("cancels install form and clears path", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/some/path");
const cancelBtn = screen.getByRole("button", { name: /Cancel/i });
await userEvent.click(cancelBtn);
// Form should be gone
expect(
screen.queryByPlaceholderText(
"Absolute path to plugin directory or dist folder",
),
).toBeNull();
// Re-opening should show empty input
await openInstallForm();
expect(getPathInput().value).toBe("");
});
});

View File

@@ -73,6 +73,11 @@ vi.mock("../../api", () => ({
settings: {},
settingsSchema: {},
})),
browseDirectory: vi.fn(() => Promise.resolve({
currentPath: "/home",
parentPath: "/",
entries: [{ name: "plugins", path: "/home/plugins", hasChildren: true }],
})),
}));
// Import after vi.mock so the mock is in place
@@ -175,7 +180,7 @@ describe("PluginManager", () => {
});
expect(screen.getByText("No plugins installed.")).toBeTruthy();
expect(screen.getByRole("button", { name: /Install$/ })).toBeTruthy();
expect(screen.getByRole("button", { name: /^Install$/ })).toBeTruthy();
});
it("renders plugin list when plugins are available", async () => {
@@ -192,17 +197,24 @@ describe("PluginManager", () => {
expect(screen.getByText("v2.0.0")).toBeTruthy();
});
it("shows install form when Install button is clicked", async () => {
it("shows install form with directory picker and hint when Install button is clicked", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
const installButton = screen.getByRole("button", { name: /Install$/ });
const installButton = screen.getByRole("button", { name: /^Install$/ });
await userEvent.click(installButton);
expect(screen.getByPlaceholderText("Local path to plugin directory")).toBeTruthy();
// Directory picker input is present
expect(screen.getByPlaceholderText("Absolute path to plugin directory or dist folder")).toBeTruthy();
// Browse button from DirectoryPicker is present
expect(screen.getByRole("button", { name: /Browse directories|Close directory browser/i })).toBeTruthy();
// Hint text about valid selections
expect(screen.getByText(/manifest\.json/)).toBeTruthy();
expect(screen.getByText(/dist/)).toBeTruthy();
// Cancel button
expect(screen.getByRole("button", { name: /Cancel/i })).toBeTruthy();
});
@@ -214,16 +226,16 @@ describe("PluginManager", () => {
});
// Click the header Install button
const headerInstallButton = screen.getByRole("button", { name: /Install$/ });
const headerInstallButton = screen.getByRole("button", { name: /^Install$/ });
await userEvent.click(headerInstallButton);
const input = screen.getByPlaceholderText("Local path to plugin directory");
const input = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder");
await userEvent.type(input, "/path/to/plugin");
// Get the form container and find the Install button within it
const formContainer = screen.getByPlaceholderText("Local path to plugin directory").closest(".plugin-install-form");
// Get the form container and find the Install Plugin button within it
const formContainer = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder").closest(".plugin-install-form");
expect(formContainer).toBeTruthy();
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install$/ });
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install Plugin/ });
await userEvent.click(formInstallButton);
await waitFor(() => {
@@ -240,15 +252,15 @@ describe("PluginManager", () => {
expect(fetchPlugins).toHaveBeenCalled();
});
const headerInstallButton = screen.getByRole("button", { name: /Install$/ });
const headerInstallButton = screen.getByRole("button", { name: /^Install$/ });
await userEvent.click(headerInstallButton);
const input = screen.getByPlaceholderText("Local path to plugin directory");
const input = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder");
await userEvent.type(input, "/path/to/plugin");
const formContainer = screen.getByPlaceholderText("Local path to plugin directory").closest(".plugin-install-form");
const formContainer = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder").closest(".plugin-install-form");
expect(formContainer).toBeTruthy();
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install$/ });
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install Plugin/ });
await userEvent.click(formInstallButton);
await waitFor(() => {