mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal plugin offers and the ai plugin fills, gated on ai.services.use.
This commit is contained in:
1 parent
225f618337
commit
2d6c1cdecb
29 files changed
+1971
-40
No files matched your search
@@ -29,6 +29,16 @@ vi.mock("@/sidebar/tree/HostItem/HostItem", () => hostActionsApi);
|
||||
vi.mock("@/hooks/use-mobile", () => ({
|
||||
useIsMobile: () => mobileApi.isMobile,
|
||||
}));
|
||||
const perms = vi.hoisted(() => ({ granted: [] as string[] }));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({
|
||||
permissions: perms.granted,
|
||||
isAdmin: false,
|
||||
loaded: true,
|
||||
has: (permission: string) => perms.granted.includes(permission),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
@@ -54,11 +64,18 @@ vi.mock("react-i18next", () => ({
|
||||
"terminalToolbar.imageActionFailed": "Image action failed",
|
||||
"terminalToolbar.retry": "Retry",
|
||||
"terminalToolbar.dismissError": "Dismiss image error",
|
||||
"ai.assistant": "AI Assistant",
|
||||
})[key] ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TerminalToolbar } from "@/features/terminal/TerminalToolbar";
|
||||
import {
|
||||
declareActionSlot,
|
||||
registerAction,
|
||||
registerSlotContribution,
|
||||
resetActionRegistry,
|
||||
} from "@/shell/action-registry";
|
||||
import {
|
||||
clampToolbarPosition,
|
||||
getResponsiveToolbarDensity,
|
||||
@@ -96,6 +113,8 @@ beforeEach(() => {
|
||||
value: 1024,
|
||||
});
|
||||
localStorage.clear();
|
||||
perms.granted = [];
|
||||
resetActionRegistry();
|
||||
hostActionsApi.getSshActions.mockReturnValue([]);
|
||||
api.startMetricsPolling.mockResolvedValue({ viewerSessionId: "viewer" });
|
||||
api.getServerMetricsById.mockResolvedValue(null);
|
||||
@@ -891,4 +910,67 @@ describe("TerminalToolbar Phase 1", () => {
|
||||
expect(api.stopMetricsPolling).toHaveBeenCalledWith(7, "late-viewer"),
|
||||
);
|
||||
});
|
||||
|
||||
describe("contributed toolbar actions", () => {
|
||||
const ACTION = "ai.openWithContext";
|
||||
|
||||
function contribute() {
|
||||
const handler = vi.fn();
|
||||
declareActionSlot({ id: "terminal.toolbar", accepts: ["button"] });
|
||||
registerAction(ACTION, handler, { permission: "ai.services.use" });
|
||||
registerSlotContribution("terminal.toolbar", {
|
||||
actionId: ACTION,
|
||||
titleKey: "ai.assistant",
|
||||
});
|
||||
return handler;
|
||||
}
|
||||
|
||||
it("shows no contributed button and no stray separator when nothing contributed", () => {
|
||||
declareActionSlot({ id: "terminal.toolbar", accepts: ["button"] });
|
||||
const { container } = renderToolbar();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
||||
const separatorsWithout = container.querySelectorAll(".w-px").length;
|
||||
|
||||
cleanup();
|
||||
contribute();
|
||||
perms.granted = ["ai.services.use"];
|
||||
const withButton = renderToolbar().container;
|
||||
|
||||
expect(withButton.querySelectorAll(".w-px").length).toBe(
|
||||
separatorsWithout + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the contributed button when the user lacks the permission", () => {
|
||||
contribute();
|
||||
renderToolbar();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes the action with the terminal buffer text when permitted", async () => {
|
||||
const handler = contribute();
|
||||
perms.granted = ["ai.services.use"];
|
||||
const getBufferText = vi.fn(() => "$ whoami\nroot");
|
||||
|
||||
renderToolbar({ getBufferText });
|
||||
|
||||
expect(getBufferText).not.toHaveBeenCalled();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "AI Assistant" }),
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith("$ whoami\nroot");
|
||||
});
|
||||
|
||||
it("hides the slot when the host has contributed actions disabled", () => {
|
||||
contribute();
|
||||
perms.granted = ["ai.services.use"];
|
||||
|
||||
renderToolbar({ actionsEnabled: false });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { getTerminalBufferText } from "@/features/terminal/terminal-buffer-text";
|
||||
|
||||
interface FakeLine {
|
||||
text: string;
|
||||
wrapped?: boolean;
|
||||
}
|
||||
|
||||
/** Minimal stand-in for the parts of xterm this reads. */
|
||||
function fakeTerminal(lines: FakeLine[], rows = lines.length): Terminal {
|
||||
return {
|
||||
rows,
|
||||
buffer: {
|
||||
active: {
|
||||
baseY: Math.max(0, lines.length - rows),
|
||||
getLine: (i: number) => {
|
||||
const line = lines[i];
|
||||
if (!line) return undefined;
|
||||
return {
|
||||
isWrapped: !!line.wrapped,
|
||||
translateToString: () => line.text,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Terminal;
|
||||
}
|
||||
|
||||
describe("getTerminalBufferText", () => {
|
||||
it("returns an empty string for a missing terminal", () => {
|
||||
expect(getTerminalBufferText(null)).toBe("");
|
||||
expect(getTerminalBufferText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("joins buffer rows with newlines", () => {
|
||||
const terminal = fakeTerminal([{ text: "$ whoami" }, { text: "root" }]);
|
||||
|
||||
expect(getTerminalBufferText(terminal)).toBe("$ whoami\nroot");
|
||||
});
|
||||
|
||||
it("joins a wrapped row onto the line it continues", () => {
|
||||
const terminal = fakeTerminal([
|
||||
{ text: "$ echo start" },
|
||||
{ text: "a-very-long-command " },
|
||||
{ text: "that-wrapped", wrapped: true },
|
||||
]);
|
||||
|
||||
expect(getTerminalBufferText(terminal)).toBe(
|
||||
"$ echo start\na-very-long-command that-wrapped",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops trailing blank lines", () => {
|
||||
const terminal = fakeTerminal([
|
||||
{ text: "output" },
|
||||
{ text: "" },
|
||||
{ text: "" },
|
||||
]);
|
||||
|
||||
expect(getTerminalBufferText(terminal)).toBe("output");
|
||||
});
|
||||
|
||||
it("keeps blank lines in the middle", () => {
|
||||
const terminal = fakeTerminal([
|
||||
{ text: "one" },
|
||||
{ text: "" },
|
||||
{ text: "two" },
|
||||
]);
|
||||
|
||||
expect(getTerminalBufferText(terminal)).toBe("one\n\ntwo");
|
||||
});
|
||||
|
||||
it("bounds the result to maxLines, keeping the tail", () => {
|
||||
const lines = Array.from({ length: 50 }, (_, i) => ({ text: `line ${i}` }));
|
||||
const terminal = fakeTerminal(lines, 50);
|
||||
|
||||
const result = getTerminalBufferText(terminal, 3);
|
||||
|
||||
expect(result).toBe("line 47\nline 48\nline 49");
|
||||
});
|
||||
|
||||
it("returns an empty string for a non-positive maxLines", () => {
|
||||
const terminal = fakeTerminal([{ text: "anything" }]);
|
||||
|
||||
expect(getTerminalBufferText(terminal, 0)).toBe("");
|
||||
});
|
||||
|
||||
it("returns an empty string when the buffer throws", () => {
|
||||
const disposed = {
|
||||
rows: 1,
|
||||
get buffer(): never {
|
||||
throw new Error("terminal disposed");
|
||||
},
|
||||
} as unknown as Terminal;
|
||||
|
||||
expect(getTerminalBufferText(disposed)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matchesPermission } from "@/lib/permissions";
|
||||
|
||||
/**
|
||||
* These cases mirror PermissionManager.hasPermission on the backend. If that
|
||||
* changes, this file is the tripwire.
|
||||
*/
|
||||
describe("matchesPermission", () => {
|
||||
it("grants on an exact match", () => {
|
||||
expect(
|
||||
matchesPermission(["ai.services.use"], false, "ai.services.use"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("grants on the superuser wildcard", () => {
|
||||
expect(matchesPermission(["*"], false, "anything.at.all")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants on each dotted wildcard level", () => {
|
||||
expect(matchesPermission(["ai.services.*"], false, "ai.services.use")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchesPermission(["ai.*"], false, "ai.services.use")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants to an admin with no explicit permissions", () => {
|
||||
expect(matchesPermission([], true, "ai.services.use")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies a non-admin without a matching grant", () => {
|
||||
expect(matchesPermission([], false, "ai.services.use")).toBe(false);
|
||||
expect(matchesPermission(["hosts.view"], false, "ai.services.use")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat a prefix as a wildcard without the star", () => {
|
||||
expect(matchesPermission(["ai.services"], false, "ai.services.use")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a sibling wildcard grant an unrelated permission", () => {
|
||||
expect(matchesPermission(["hosts.*"], false, "ai.services.use")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a single-segment permission", () => {
|
||||
expect(matchesPermission(["admin"], false, "admin")).toBe(true);
|
||||
// "admin.*" covers bare "admin" too: the backend builds the same
|
||||
// single-segment wildcard, so the frontend must agree.
|
||||
expect(matchesPermission(["admin.*"], false, "admin")).toBe(true);
|
||||
expect(matchesPermission(["other"], false, "admin")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* The three states a permission-gated slot contribution can be in, plus the
|
||||
* reactivity that makes a slot different from the rail/tab registries.
|
||||
*
|
||||
* Follows the pattern of sidebar/plugin-extension-seam.test.tsx: register into
|
||||
* the real registry, assert through a real render, then clean up. The point is
|
||||
* that states (a) "nothing contributed" and (b) "contributed but not
|
||||
* permitted" are indistinguishable in the DOM.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
const perms = vi.hoisted(() => ({
|
||||
permissions: [] as string[],
|
||||
isAdmin: false,
|
||||
loaded: true,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-permissions", async () => {
|
||||
const { matchesPermission } = await import("@/lib/permissions");
|
||||
return {
|
||||
usePermissions: () => ({
|
||||
permissions: perms.permissions,
|
||||
isAdmin: perms.isAdmin,
|
||||
loaded: perms.loaded,
|
||||
has: (permission: string) =>
|
||||
matchesPermission(perms.permissions, perms.isAdmin, permission),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { ActionSlot } from "@/shell/ActionSlot";
|
||||
import {
|
||||
declareActionSlot,
|
||||
registerAction,
|
||||
registerSlotContribution,
|
||||
resetActionRegistry,
|
||||
getSlotContributions,
|
||||
unregisterSlotContribution,
|
||||
} from "@/shell/action-registry";
|
||||
|
||||
const SLOT = "terminal.toolbar";
|
||||
const ACTION = "ai.openWithContext";
|
||||
|
||||
function contributeAi(handler = vi.fn()) {
|
||||
registerAction(ACTION, handler, { permission: "ai.services.use" });
|
||||
registerSlotContribution(SLOT, {
|
||||
actionId: ACTION,
|
||||
titleKey: "ai.assistant",
|
||||
icon: Bot,
|
||||
});
|
||||
return handler;
|
||||
}
|
||||
|
||||
describe("ActionSlot", () => {
|
||||
beforeEach(() => {
|
||||
perms.permissions = [];
|
||||
perms.isAdmin = false;
|
||||
perms.loaded = true;
|
||||
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetActionRegistry();
|
||||
});
|
||||
|
||||
it("renders nothing when no plugin has contributed", () => {
|
||||
const { container } = render(<ActionSlot slotId={SLOT} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when the user lacks the permission", () => {
|
||||
const handler = contributeAi();
|
||||
|
||||
const { container } = render(<ActionSlot slotId={SLOT} />);
|
||||
|
||||
// Identical to the no-contribution case: no placeholder, and nothing
|
||||
// merely disabled or greyed out that hints at a feature out of reach.
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.queryByText("ai.assistant")).toBeNull();
|
||||
expect(container.querySelector("[disabled]")).toBeNull();
|
||||
expect(container.querySelector("[aria-disabled]")).toBeNull();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the button and invokes the action when permitted", async () => {
|
||||
const handler = contributeAi();
|
||||
perms.permissions = ["ai.services.use"];
|
||||
|
||||
render(<ActionSlot slotId={SLOT} context={() => ["buffer text"]} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "ai.assistant" });
|
||||
await act(async () => {
|
||||
button.click();
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith("buffer text");
|
||||
});
|
||||
|
||||
it("reads its context at click time, not on render", async () => {
|
||||
const handler = contributeAi();
|
||||
perms.permissions = ["ai.services.use"];
|
||||
const context = vi.fn(() => ["later"]);
|
||||
|
||||
render(<ActionSlot slotId={SLOT} context={context} />);
|
||||
expect(context).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole("button", { name: "ai.assistant" }).click();
|
||||
});
|
||||
expect(context).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith("later");
|
||||
});
|
||||
|
||||
it("appears when a contribution is registered after mount", () => {
|
||||
perms.permissions = ["ai.services.use"];
|
||||
|
||||
render(<ActionSlot slotId={SLOT} />);
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
|
||||
// No re-render from the test: useSyncExternalStore has to do this. A
|
||||
// plain Map like the rail/tab registries would fail here.
|
||||
act(() => {
|
||||
contributeAi();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disappears again when the contribution is removed", () => {
|
||||
contributeAi();
|
||||
perms.permissions = ["ai.services.use"];
|
||||
|
||||
render(<ActionSlot slotId={SLOT} />);
|
||||
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
unregisterSlotContribution(SLOT, ACTION);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a wildcard-granted contribution", () => {
|
||||
contributeAi();
|
||||
perms.permissions = ["ai.*"];
|
||||
|
||||
render(<ActionSlot slotId={SLOT} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders for an admin with no explicit grants", () => {
|
||||
contributeAi();
|
||||
perms.isAdmin = true;
|
||||
|
||||
render(<ActionSlot slotId={SLOT} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "ai.assistant" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an ungated contribution for everyone", () => {
|
||||
registerAction("demo.ping", vi.fn());
|
||||
registerSlotContribution(SLOT, {
|
||||
actionId: "demo.ping",
|
||||
titleKey: "demo.ping",
|
||||
});
|
||||
|
||||
render(<ActionSlot slotId={SLOT} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "demo.ping" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing while permissions are still loading", () => {
|
||||
contributeAi();
|
||||
perms.permissions = ["ai.services.use"];
|
||||
perms.loaded = false;
|
||||
|
||||
const { container } = render(<ActionSlot slotId={SLOT} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when the owner disables the slot", () => {
|
||||
contributeAi();
|
||||
perms.permissions = ["ai.services.use"];
|
||||
|
||||
const { container } = render(<ActionSlot slotId={SLOT} enabled={false} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("lets the slot owner render contributions its own way", () => {
|
||||
contributeAi();
|
||||
perms.permissions = ["ai.services.use"];
|
||||
|
||||
render(
|
||||
<ActionSlot
|
||||
slotId={SLOT}
|
||||
renderItem={(contribution, invoke) => (
|
||||
<span data-testid="custom" onClick={invoke}>
|
||||
{contribution.titleKey}
|
||||
</span>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("custom")).toHaveTextContent("ai.assistant");
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("action registry", () => {
|
||||
afterEach(() => {
|
||||
resetActionRegistry();
|
||||
});
|
||||
|
||||
it("refuses a contribution whose kind the slot does not accept", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||
|
||||
registerSlotContribution(SLOT, {
|
||||
actionId: "demo.menu",
|
||||
titleKey: "demo.menu",
|
||||
kind: "menu-item",
|
||||
});
|
||||
|
||||
expect(getSlotContributions(SLOT)).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps a contribution registered before its slot is declared", () => {
|
||||
registerSlotContribution(SLOT, {
|
||||
actionId: ACTION,
|
||||
titleKey: "ai.assistant",
|
||||
});
|
||||
|
||||
expect(getSlotContributions(SLOT)).toHaveLength(1);
|
||||
|
||||
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||
expect(getSlotContributions(SLOT)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns a stable snapshot reference between mutations", () => {
|
||||
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||
registerSlotContribution(SLOT, {
|
||||
actionId: ACTION,
|
||||
titleKey: "ai.assistant",
|
||||
});
|
||||
|
||||
// The guard against useSyncExternalStore's infinite re-render loop.
|
||||
expect(getSlotContributions(SLOT)).toBe(getSlotContributions(SLOT));
|
||||
|
||||
const before = getSlotContributions(SLOT);
|
||||
registerSlotContribution(SLOT, {
|
||||
actionId: "demo.other",
|
||||
titleKey: "demo.other",
|
||||
});
|
||||
expect(getSlotContributions(SLOT)).not.toBe(before);
|
||||
});
|
||||
|
||||
it("sorts by order then action id", () => {
|
||||
declareActionSlot({ id: SLOT, accepts: ["button"] });
|
||||
registerSlotContribution(SLOT, { actionId: "z.last", titleKey: "k" });
|
||||
registerSlotContribution(SLOT, { actionId: "a.first", titleKey: "k" });
|
||||
registerSlotContribution(SLOT, {
|
||||
actionId: "m.leading",
|
||||
titleKey: "k",
|
||||
order: -1,
|
||||
});
|
||||
|
||||
expect(getSlotContributions(SLOT).map((c) => c.actionId)).toEqual([
|
||||
"m.leading",
|
||||
"a.first",
|
||||
"z.last",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves quietly when a contribution points at a missing action", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { invokeAction } = await import("@/shell/action-registry");
|
||||
|
||||
await expect(invokeAction("nobody.home")).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user