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:
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user